@micha.bigler/ui-core-micha 2.24.0 → 2.25.1
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 +4 -1
- package/dist/i18n/messagingTranslations.js +1 -0
- package/dist/i18n/notificationsTranslations.js +2 -0
- package/dist/messaging/AttachmentList.js +2 -2
- package/dist/messaging/Composer.js +13 -3
- package/dist/messaging/MessagingProvider.js +0 -0
- package/dist/messaging/PollCard.js +19 -4
- package/dist/messaging/Thread.js +1 -1
- package/dist/notifications/NotificationSettings.js +17 -1
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +1 -0
- package/src/i18n/notificationsTranslations.ts +2 -0
- package/src/messaging/AttachmentList.jsx +6 -3
- package/src/messaging/Composer.jsx +35 -5
- package/src/messaging/MessagingProvider.jsx +0 -0
- package/src/messaging/PollCard.jsx +20 -5
- package/src/messaging/Thread.jsx +13 -2
- package/src/notifications/NotificationSettings.jsx +27 -0
- package/tests/NotificationSettings.test.jsx +92 -3
- package/tests/messagingComposerChunk5.test.jsx +4 -1
- package/tests/messagingMsg6i.test.jsx +129 -0
- package/work-orders/MSG-6i.md +271 -0
- package/work-orders/NOTIF-14.md +151 -0
- package/work-orders/SHELL-1.md +255 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
-
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
5
|
|
|
6
6
|
const notificationsApi = vi.hoisted(() => ({
|
|
7
7
|
getNotificationPreferences: vi.fn(),
|
|
@@ -13,7 +13,12 @@ const notificationsApi = vi.hoisted(() => ({
|
|
|
13
13
|
}));
|
|
14
14
|
|
|
15
15
|
vi.mock('../src/notifications/api', () => notificationsApi);
|
|
16
|
-
|
|
16
|
+
// A stable `t` reference, matching real react-i18next (which memoizes it) -- an unstable
|
|
17
|
+
// mock `t` would re-trigger the component's `useEffect(..., [t])` on every render and
|
|
18
|
+
// re-fetch preferences, clobbering any state update made in between (e.g. a toggle's
|
|
19
|
+
// optimistic-from-response update racing against a stale refetch).
|
|
20
|
+
const stableT = (key) => key;
|
|
21
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: stableT }) }));
|
|
17
22
|
|
|
18
23
|
import { NotificationSettings } from '../src/notifications/NotificationSettings';
|
|
19
24
|
|
|
@@ -32,6 +37,11 @@ function installPushEnvironment(subscription) {
|
|
|
32
37
|
return registration;
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
cleanup();
|
|
42
|
+
vi.unstubAllGlobals();
|
|
43
|
+
});
|
|
44
|
+
|
|
35
45
|
describe('NotificationSettings push toggle', () => {
|
|
36
46
|
beforeEach(() => {
|
|
37
47
|
vi.clearAllMocks();
|
|
@@ -80,3 +90,82 @@ describe('NotificationSettings push toggle', () => {
|
|
|
80
90
|
expect(notificationsApi.patchNotificationPreferences).not.toHaveBeenCalled();
|
|
81
91
|
});
|
|
82
92
|
});
|
|
93
|
+
|
|
94
|
+
describe('NotificationSettings push preview toggle', () => {
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
vi.clearAllMocks();
|
|
97
|
+
notificationsApi.getVapidPublicKey.mockResolvedValue('AQID');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('renders checked when the preference is explicitly on', async () => {
|
|
101
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({
|
|
102
|
+
email_opt_in: false, push_opt_in: true, push_preview_opt_in: true,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
render(<NotificationSettings />);
|
|
106
|
+
|
|
107
|
+
const toggle = await screen.findByRole('switch', { name: /PUSH_PREVIEW_LABEL/ });
|
|
108
|
+
expect(toggle.checked).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('renders checked when the preference is absent entirely (default-on, not Boolean(undefined))', async () => {
|
|
112
|
+
// Regression test for NOTIF-14: a naive `Boolean(preferences?.push_preview_opt_in)`
|
|
113
|
+
// reads a missing field as false, misrepresenting the backend's true default. Must
|
|
114
|
+
// fail against that implementation.
|
|
115
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({ email_opt_in: false, push_opt_in: true });
|
|
116
|
+
|
|
117
|
+
render(<NotificationSettings />);
|
|
118
|
+
|
|
119
|
+
const toggle = await screen.findByRole('switch', { name: /PUSH_PREVIEW_LABEL/ });
|
|
120
|
+
expect(toggle.checked).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('renders unchecked when the preference is explicitly off', async () => {
|
|
124
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({
|
|
125
|
+
email_opt_in: false, push_opt_in: true, push_preview_opt_in: false,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
render(<NotificationSettings />);
|
|
129
|
+
|
|
130
|
+
const toggle = await screen.findByRole('switch', { name: /PUSH_PREVIEW_LABEL/ });
|
|
131
|
+
expect(toggle.checked).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('toggling calls patchNotificationPreferences with push_preview_opt_in and re-renders from the response', async () => {
|
|
135
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({
|
|
136
|
+
email_opt_in: false, push_opt_in: true, push_preview_opt_in: true,
|
|
137
|
+
});
|
|
138
|
+
notificationsApi.patchNotificationPreferences.mockResolvedValue({
|
|
139
|
+
email_opt_in: false, push_opt_in: true, push_preview_opt_in: false,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
render(<NotificationSettings />);
|
|
143
|
+
const toggle = await screen.findByRole('switch', { name: /PUSH_PREVIEW_LABEL/ });
|
|
144
|
+
fireEvent.click(toggle);
|
|
145
|
+
|
|
146
|
+
await waitFor(() => expect(notificationsApi.patchNotificationPreferences).toHaveBeenCalledWith({ push_preview_opt_in: false }));
|
|
147
|
+
await waitFor(() => expect(toggle.checked).toBe(false));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('is disabled when push notifications are not opted in for this account', async () => {
|
|
151
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({
|
|
152
|
+
email_opt_in: false, push_opt_in: false, push_preview_opt_in: true,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
render(<NotificationSettings />);
|
|
156
|
+
|
|
157
|
+
const toggle = await screen.findByRole('switch', { name: /PUSH_PREVIEW_LABEL/ });
|
|
158
|
+
expect(toggle.disabled).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('is enabled when push notifications are opted in for this account', async () => {
|
|
162
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({
|
|
163
|
+
email_opt_in: false, push_opt_in: true, push_preview_opt_in: true,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
render(<NotificationSettings />);
|
|
167
|
+
|
|
168
|
+
const toggle = await screen.findByRole('switch', { name: /PUSH_PREVIEW_LABEL/ });
|
|
169
|
+
expect(toggle.disabled).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -90,10 +90,13 @@ describe('Composer chunk 5 behavior', () => {
|
|
|
90
90
|
});
|
|
91
91
|
|
|
92
92
|
it('inserts the selected emoji at the current cursor position', async () => {
|
|
93
|
+
// MSG-6i: the Composer's own picker is now a wider curated grid
|
|
94
|
+
// (IconButton per emoji), decoupled from ReactionBar's 5-entry
|
|
95
|
+
// QUICK_EMOJIS -- role is "button", not "menuitem".
|
|
93
96
|
renderComposer(); fireEvent.change(messageInput(), { target: { value: 'abCD' } });
|
|
94
97
|
messageInput().setSelectionRange(2, 2);
|
|
95
98
|
fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.ADD_EMOJI' }));
|
|
96
|
-
fireEvent.click(await screen.findByRole('
|
|
99
|
+
fireEvent.click(await screen.findByRole('button', { name: '👍' }));
|
|
97
100
|
await waitFor(() => expect(messageInput().value).toBe('ab👍CD'));
|
|
98
101
|
});
|
|
99
102
|
|
|
@@ -0,0 +1,129 @@
|
|
|
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, within } 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 { Composer } from '../src/messaging/Composer';
|
|
12
|
+
import { MessagingProvider } from '../src/messaging/MessagingProvider';
|
|
13
|
+
import { PollCard } from '../src/messaging/PollCard';
|
|
14
|
+
import { QUICK_EMOJIS } from '../src/messaging/ReactionBar';
|
|
15
|
+
import { Thread } from '../src/messaging/Thread';
|
|
16
|
+
|
|
17
|
+
function makeApi(overrides = {}) {
|
|
18
|
+
return {
|
|
19
|
+
listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
20
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
21
|
+
getAttachment: vi.fn().mockResolvedValue(new Blob(['bytes'], { type: 'image/jpeg' })),
|
|
22
|
+
getAttachmentThumbnail: vi.fn().mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' })),
|
|
23
|
+
createMessage: vi.fn(), uploadAttachments: vi.fn(),
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
afterEach(cleanup);
|
|
29
|
+
|
|
30
|
+
describe('MSG-6i: attachment tile size, poll spacing/denominator, Composer emoji picker', () => {
|
|
31
|
+
it('1. an attachment tile renders at 120x120 (image and non-image)', async () => {
|
|
32
|
+
const image = { id: 'a1', filename: 'photo.jpg', content_type: 'image/jpeg' };
|
|
33
|
+
const file = { id: 'a2', filename: 'doc.pdf', content_type: 'application/pdf' };
|
|
34
|
+
render(<MessagingProvider api={makeApi()} active={false}><AttachmentList attachments={[image, file]} /></MessagingProvider>);
|
|
35
|
+
const tiles = await screen.findAllByRole('button');
|
|
36
|
+
const gallery = tiles.filter((tile) => tile.getAttribute('title') === 'photo.jpg' || tile.getAttribute('title') === 'doc.pdf');
|
|
37
|
+
expect(gallery).toHaveLength(2);
|
|
38
|
+
gallery.forEach((tile) => {
|
|
39
|
+
// MUI applies `sx={{ width, height }}` via a generated CSS class, not
|
|
40
|
+
// an inline style attribute -- read the resolved value instead.
|
|
41
|
+
expect(getComputedStyle(tile).width).toBe('120px');
|
|
42
|
+
expect(getComputedStyle(tile).height).toBe('120px');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('2. poll percentage denominator is the count of distinct respondents, not sum(vote_count) -- must fail against the old sum(vote_count) code for a multi-select poll where one voter picked two options', () => {
|
|
47
|
+
// One respondent (voter-1) voted for both options. sum(vote_count) = 2,
|
|
48
|
+
// respondent count = 1. Old code: 1/2 = 50% each. New code: 1/1 = 100% each.
|
|
49
|
+
const poll = {
|
|
50
|
+
id: 'poll-1', question: 'Q', allow_multiple: true, closed_at: null,
|
|
51
|
+
options: [
|
|
52
|
+
{ id: 'opt-a', text: 'A', order: 0, vote_count: 1, voters: ['voter-1'] },
|
|
53
|
+
{ id: 'opt-b', text: 'B', order: 1, vote_count: 1, voters: ['voter-1'] },
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
render(<MessagingProvider api={makeApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider>);
|
|
57
|
+
const results = screen.getAllByText(/MessagingPoll\.OPTION_RESULT/);
|
|
58
|
+
expect(results).toHaveLength(2);
|
|
59
|
+
results.forEach((node) => {
|
|
60
|
+
expect(node.textContent).toMatch(/"count":1/);
|
|
61
|
+
expect(node.textContent).toMatch(/"percent":100/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('3. poll percentage denominator: zero respondents still renders 0%, never NaN (no regression)', () => {
|
|
66
|
+
const poll = {
|
|
67
|
+
id: 'poll-2', question: 'Q', allow_multiple: false, closed_at: null,
|
|
68
|
+
options: [
|
|
69
|
+
{ id: 'opt-a', text: 'A', order: 0, vote_count: 0, voters: [] },
|
|
70
|
+
{ id: 'opt-b', text: 'B', order: 1, vote_count: 0, voters: [] },
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
render(<MessagingProvider api={makeApi()} active={false}><PollCard message={{ id: 'msg-2', poll }} /></MessagingProvider>);
|
|
74
|
+
const results = screen.getAllByText(/MessagingPoll\.OPTION_RESULT/);
|
|
75
|
+
results.forEach((node) => {
|
|
76
|
+
expect(node.textContent).toMatch(/"count":0/);
|
|
77
|
+
expect(node.textContent).toMatch(/"percent":0/);
|
|
78
|
+
expect(node.textContent).not.toMatch(/NaN/);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('4. the Composer emoji picker shows more than 5 options (the wider curated grid, not ReactionBar\'s QUICK_EMOJIS)', async () => {
|
|
83
|
+
const api = makeApi();
|
|
84
|
+
render(<MessagingProvider api={api} active={false}><Composer conversationId={4} /></MessagingProvider>);
|
|
85
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.ADD_EMOJI' }));
|
|
86
|
+
const grid = await screen.findByRole('group', { name: 'MessagingComposer.EMOJI_PICKER' });
|
|
87
|
+
const emojiButtons = grid.querySelectorAll('button');
|
|
88
|
+
expect(emojiButtons.length).toBeGreaterThan(5);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('4b. selecting an emoji from the Composer grid inserts it at the cursor position, not appended', async () => {
|
|
92
|
+
const api = makeApi();
|
|
93
|
+
render(<MessagingProvider api={api} active={false}><Composer conversationId={4} /></MessagingProvider>);
|
|
94
|
+
const input = screen.getByLabelText('MessagingComposer.MESSAGE');
|
|
95
|
+
fireEvent.change(input, { target: { value: 'abCD' } });
|
|
96
|
+
input.setSelectionRange(2, 2);
|
|
97
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.ADD_EMOJI' }));
|
|
98
|
+
const grid = await screen.findByRole('group', { name: 'MessagingComposer.EMOJI_PICKER' });
|
|
99
|
+
fireEvent.click(within(grid).getByRole('button', { name: '😀' }));
|
|
100
|
+
await waitFor(() => expect(input.value).toBe('ab😀CD'));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('5. ReactionBar\'s own QUICK_EMOJIS stays exactly 5, unaffected by the wider Composer picker', () => {
|
|
104
|
+
expect(QUICK_EMOJIS).toHaveLength(5);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('6. Thread\'s root Stack and scroll Box carry flex:1/minHeight:0 (MSG-18 companion fix), so it can shrink inside a host\'s flex-column layout', async () => {
|
|
108
|
+
const api = makeApi({
|
|
109
|
+
listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, kind: 'group' }] }),
|
|
110
|
+
listMessages: vi.fn().mockResolvedValue({ results: [{ id: 2, conversation_id: 1, body: 'hi' }], next_cursor: null }),
|
|
111
|
+
listThread: vi.fn().mockResolvedValue({ results: [] }),
|
|
112
|
+
getReadStatus: vi.fn().mockResolvedValue({ all_read: true }),
|
|
113
|
+
});
|
|
114
|
+
render(
|
|
115
|
+
<AuthContext.Provider value={{ user: { id: 9 } }}>
|
|
116
|
+
<MessagingProvider api={api}><Thread conversationId={1} /></MessagingProvider>
|
|
117
|
+
</AuthContext.Provider>,
|
|
118
|
+
);
|
|
119
|
+
const timeline = await screen.findByLabelText('MessagingThread.TIMELINE');
|
|
120
|
+
// The scroll Box is a direct child of Thread's root Stack.
|
|
121
|
+
const rootStack = timeline.parentElement;
|
|
122
|
+
[timeline, rootStack].forEach((el) => {
|
|
123
|
+
expect(getComputedStyle(el).flexGrow).toBe('1');
|
|
124
|
+
expect(getComputedStyle(el).minHeight).toBe('0px');
|
|
125
|
+
});
|
|
126
|
+
// maxHeight stays as the fallback cap for a host that doesn't flex-wrap Thread.
|
|
127
|
+
expect(getComputedStyle(timeline).maxHeight).toBe('560px');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# WORK ORDER MSG-6i (ui-core-micha) — attachment tile size, poll spacing/denominator, wider emoji picker
|
|
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 polish requests 2026-08-03, gathered in a
|
|
11
|
+
requirements round after MSG-6h shipped; four independent, small scopes bundled the same way
|
|
12
|
+
MSG-6f bundled A/B/C.
|
|
13
|
+
|
|
14
|
+
## TIER
|
|
15
|
+
Tier 2 — shared-core UI consumed by every host app. Independent `reviewer` + `ui_reviewer` mandatory
|
|
16
|
+
(scope C changes poll math, not just styling).
|
|
17
|
+
|
|
18
|
+
## SCOPE
|
|
19
|
+
|
|
20
|
+
**A. Attachment tile: 64px → 120px.**
|
|
21
|
+
|
|
22
|
+
`AttachmentList.jsx`'s tile `sx={{ width: 64, height: 64, ... }}` (both the `ButtonBase` and its
|
|
23
|
+
internal thumbnail `Box component="img"`, which is `width: '100%', height: '100%'` and inherits).
|
|
24
|
+
Change both dimensions to `120`. Check the non-image tile's icon/caption layout still reads well at
|
|
25
|
+
the larger size (currently sized for 64px — `InsertDriveFileOutlinedIcon fontSize="small"` and a
|
|
26
|
+
`caption` typography may look sparse/small inside a 120px box; consider `fontSize="medium"` or larger
|
|
27
|
+
if it looks disproportionate — operator did not specify, use judgement, flag if genuinely unsure).
|
|
28
|
+
`Stack direction="row" flexWrap="wrap"` spacing stays as-is unless the larger tiles visibly break
|
|
29
|
+
wrapping at typical bubble widths.
|
|
30
|
+
|
|
31
|
+
**B. Poll card: more breathing room, not a wider bubble.**
|
|
32
|
+
|
|
33
|
+
Operator, verbatim (after seeing a screenshot of the current cramped layout — checkbox, option text,
|
|
34
|
+
and percentage squeezed onto one tight line, thin bars close to the text, minimal gap between options):
|
|
35
|
+
*"Nicht generell breiter, aber luftiger."* (Not generally wider, but airier.) This is **not** scope A
|
|
36
|
+
of a width change — the poll's outer bubble width stays exactly as-is (still shares
|
|
37
|
+
`MessageBubble.jsx`'s `maxWidth: min(75%, 680px)` with every other message type). Increase internal
|
|
38
|
+
spacing in `PollCard.jsx`: the `Stack spacing={0.75}` wrapping the whole card, the per-option
|
|
39
|
+
`Stack direction="row" spacing={0.5}` (control + label), and the `LinearProgress`'s `height: 6` are
|
|
40
|
+
the likely levers — raise spacing values and/or bar height/thickness so the card reads as roomier.
|
|
41
|
+
No prescribed exact values; operator did not send further numeric guidance beyond the "airier, not
|
|
42
|
+
wider" direction — use reasonable judgement (e.g. spacing bumped by ~50%, bar height 8-10px) and
|
|
43
|
+
**flag this specific change for a live screenshot check before considering it closed** — same
|
|
44
|
+
disclosed-limitation pattern as MSG-6h's reaction-spacing fix (no staging credentials this round).
|
|
45
|
+
|
|
46
|
+
**C. Poll percentage denominator: respondents of this poll, not sum of votes cast.**
|
|
47
|
+
|
|
48
|
+
**This reverses an explicit non-goal from `MSG-6g`'s own envelope**, confirmed deliberately by the
|
|
49
|
+
operator in this round (not an oversight): MSG-6g stated *"Denominator: total votes cast, not
|
|
50
|
+
participant count. A poll's turnout relative to group size is a different question and is not in
|
|
51
|
+
scope."* — operator's correction: for a **multi-select** poll, summing `vote_count` across options can
|
|
52
|
+
exceed the number of people who actually answered (one person can vote for several options), so a
|
|
53
|
+
per-option percentage computed against that inflated sum reads as misleadingly low and the bars
|
|
54
|
+
don't sum to a sensible 100%. The new denominator: **the number of distinct people who cast at least
|
|
55
|
+
one vote in this poll** — not total conversation participants (an un-started, never-voting group
|
|
56
|
+
member should not silently deflate everyone else's percentages further).
|
|
57
|
+
|
|
58
|
+
Compute this **client-side, no backend change**: `serialize_poll`'s existing `option.voters` arrays
|
|
59
|
+
(already present, already used for the viewer's-own-vote marking) already carry every voter's id per
|
|
60
|
+
option — the union of all options' `voters` arrays, deduplicated, is exactly "everyone who answered
|
|
61
|
+
this poll." Replace `PollCard.jsx`'s current
|
|
62
|
+
`totalVotes = options.reduce((sum, option) => sum + (option.vote_count ?? option.voters?.length ?? 0), 0)`
|
|
63
|
+
with a respondent count derived from the union of `option.voters` across all options (a `Set` of voter
|
|
64
|
+
ids). **Zero-vote rendering must stay `NaN`-free** (existing guard, do not regress it) — zero
|
|
65
|
+
respondents still means 0% for every option, not a division error.
|
|
66
|
+
|
|
67
|
+
**D. Composer emoji picker: its own, wider curated list, decoupled from reaction quick-picks.**
|
|
68
|
+
|
|
69
|
+
`Composer.jsx:144` currently reuses `QUICK_EMOJIS` from `ReactionBar.jsx` (`['👍', '❤️', '😂', '🎉',
|
|
70
|
+
'👀']`, 5 entries) — the same tiny set meant for quick message reactions, not for composing free-form
|
|
71
|
+
text. Operator confirmed direction after discussion: mobile users already have a full native emoji
|
|
72
|
+
keyboard (no in-app work needed there); desktop users lack an obvious equivalent, so a **wider curated
|
|
73
|
+
grid (~20-30 common emojis)** in the Composer's own picker adds real value. **Not** a full
|
|
74
|
+
search-based picker (out of scope — meaningfully more engineering for a need mobile already covers).
|
|
75
|
+
|
|
76
|
+
Define a new, separate constant (e.g. `COMPOSER_EMOJIS` in `Composer.jsx` itself, or a shared
|
|
77
|
+
`emojiSets.js` if that reads cleaner) with ~20-30 common emojis, grouped loosely by theme (smileys,
|
|
78
|
+
gestures, hearts, objects/travel — operator did not specify an exact list; use a reasonable, common
|
|
79
|
+
default and flag it as easily adjustable). Render as a grid inside the existing `Menu` (currently a
|
|
80
|
+
plain vertical `MenuItem` list — at 25-30 entries that would be a very long scroll; switch to a
|
|
81
|
+
wrapping grid layout, e.g. a `Box` with `display: grid` or `flexWrap`, inside the `Menu`). Keep
|
|
82
|
+
`ReactionBar.jsx`'s own `QUICK_EMOJIS` untouched (5, curated, unchanged) — reactions stay small and
|
|
83
|
+
fast, this is a Composer-only change.
|
|
84
|
+
|
|
85
|
+
## NON-GOALS / DO NOT TOUCH
|
|
86
|
+
- Do not touch `ReactionBar.jsx`'s own reaction UI or its 5-emoji `QUICK_EMOJIS` — scope D adds a
|
|
87
|
+
second, separate, larger set for the Composer only.
|
|
88
|
+
- Do not widen the message bubble's `maxWidth` (`MessageBubble.jsx`) — scope B is internal spacing
|
|
89
|
+
only, confirmed explicitly by the operator.
|
|
90
|
+
- Do not touch `MessageBubble.jsx`'s reaction-overlap fix from MSG-6h, or the lightbox/context-menu
|
|
91
|
+
logic from MSG-6h — this WO only resizes the tile (scope A), doesn't change its interaction model.
|
|
92
|
+
- No backend change for scope C — the data needed already exists in the current API response.
|
|
93
|
+
- Do not build a full emoji search/picker component (scope D) — a curated grid only.
|
|
94
|
+
|
|
95
|
+
## RISKS
|
|
96
|
+
- Scope C is a **behaviour change visible to every host app** the moment this version is consumed —
|
|
97
|
+
a poll's displayed percentages will differ from before for any multi-select poll where the same
|
|
98
|
+
person voted for multiple options. State this plainly; it is the operator's own explicit correction,
|
|
99
|
+
not a silent reinterpretation.
|
|
100
|
+
- Scope A's larger tile changes vertical rhythm inside a message bubble containing attachments —
|
|
101
|
+
check it doesn't visually crowd the meta row (timestamp/read-ticks) directly below it.
|
|
102
|
+
- Scope B and D have no prescribed exact values — implement a reasonable default, disclose the
|
|
103
|
+
specific choices made (spacing multipliers, the exact emoji list) in the completion note so the
|
|
104
|
+
operator can request a numeric tweak without re-deriving what was chosen.
|
|
105
|
+
|
|
106
|
+
## REQUIRED TESTS TO WRITE
|
|
107
|
+
Narrow and behavioural. Do NOT run the full suite.
|
|
108
|
+
|
|
109
|
+
1. Attachment tile renders at 120×120 (image and non-image cases).
|
|
110
|
+
2. Poll respondent-count denominator: a multi-select poll where one voter voted for two options must
|
|
111
|
+
show percentages based on the **respondent count** (e.g. 1 respondent → both their chosen options
|
|
112
|
+
show 100%, not summed-and-diluted), not `sum(vote_count)`. This must **fail against the current
|
|
113
|
+
`sum(vote_count)` code** — prove it with a case where the two differ (multi-select, one voter,
|
|
114
|
+
two options voted).
|
|
115
|
+
3. Poll respondent-count denominator: the existing single-choice zero-vote case still shows 0%, no
|
|
116
|
+
`NaN`, no regression (reuse/extend the existing MSG-6g zero-vote test's assertion shape).
|
|
117
|
+
4. Composer emoji picker: opening it shows more than 5 emoji options (the expanded set, not
|
|
118
|
+
`ReactionBar`'s `QUICK_EMOJIS`); selecting one still inserts it at the cursor position (existing
|
|
119
|
+
`insertEmoji` behaviour, unchanged — just fed from the new list).
|
|
120
|
+
5. `ReactionBar`'s own picker is unaffected — still exactly its original 5 `QUICK_EMOJIS` (regression
|
|
121
|
+
guard that scope D didn't leak into scope-unrelated reaction UI).
|
|
122
|
+
|
|
123
|
+
## TEST SCOPE FOR THE GATE (orchestrator)
|
|
124
|
+
Attachment-, poll-, and Composer-adjacent messaging test files. Not the full suite.
|
|
125
|
+
|
|
126
|
+
## TARGET REPO
|
|
127
|
+
`C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main`. Publish per the repo's release flow;
|
|
128
|
+
the jg-ferien pin bump is part of THIS WO's completion, not a deferred step (standing correction this
|
|
129
|
+
session: publish alone is not delivery).
|
|
130
|
+
|
|
131
|
+
## MINI-HANDOVER (pastable)
|
|
132
|
+
|
|
133
|
+
> Repo: `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`). Work order:
|
|
134
|
+
> `work-orders/MSG-6i.md` — read it fully, then follow the `orchestrate-codex` skill.
|
|
135
|
+
>
|
|
136
|
+
> Four independent scopes: (A) attachment tile 64px→120px in `AttachmentList.jsx`. (B) `PollCard.jsx`
|
|
137
|
+
> internal spacing increased ("airier, not wider" — bubble maxWidth unchanged) — no exact numbers
|
|
138
|
+
> given, use judgement, flag for a live screenshot check. (C) poll percentage denominator changes from
|
|
139
|
+
> `sum(vote_count)` to the count of distinct respondents (union of all options' `voters` arrays,
|
|
140
|
+
> client-side, no backend change) — **this deliberately reverses an explicit MSG-6g non-goal**, operator
|
|
141
|
+
> re-confirmed. (D) Composer's emoji picker gets its own ~20-30-entry curated grid, decoupled from
|
|
142
|
+
> `ReactionBar`'s 5-entry `QUICK_EMOJIS` (untouched) — no full search picker, mobile already has a
|
|
143
|
+
> native one.
|
|
144
|
+
|
|
145
|
+
## PROGRESS CONTRACT
|
|
146
|
+
Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
|
|
147
|
+
relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
|
|
148
|
+
stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Part B — Implementation map (Orchestrator) / as-built record
|
|
153
|
+
|
|
154
|
+
**Codex was unavailable (out of credits) — implemented directly by the Orchestrator.**
|
|
155
|
+
|
|
156
|
+
### Scope A — `AttachmentList.jsx`
|
|
157
|
+
Tile `sx={{ width: 64, height: 64, ... }}` → `120, 120`. Non-image tile's icon bumped
|
|
158
|
+
`fontSize="small"` → `"large"` and its `Stack` spacing `0.25` → `0.75` — the original
|
|
159
|
+
small icon+caption pairing read sparse against the much larger tile.
|
|
160
|
+
|
|
161
|
+
### Scope B — `PollCard.jsx` spacing
|
|
162
|
+
Outer `Paper` padding `p: 1` → `1.5`; outer `Stack` (question/options/close-row) spacing
|
|
163
|
+
`0.75` → `1.5`; per-option `richLabel` `Stack` spacing `0.25` → `0.75`; `LinearProgress`
|
|
164
|
+
`height: 6` → `8`. No exact values were specified by the operator beyond "airier, not
|
|
165
|
+
wider" (confirmed via a screenshot of the cramped original) — **not independently
|
|
166
|
+
visually re-verified against a fresh screenshot before closing** (no staging/live
|
|
167
|
+
credentials for this specific check this round); flagging per the same disclosed-
|
|
168
|
+
limitation pattern as MSG-6h's reaction-spacing fix.
|
|
169
|
+
|
|
170
|
+
### Scope C — `PollCard.jsx` percentage denominator
|
|
171
|
+
`totalVotes = sum(option.vote_count)` replaced with
|
|
172
|
+
`respondentCount = new Set(options.flatMap(option => option.voters || [])).size` — the
|
|
173
|
+
count of distinct people who voted for *any* option, computed client-side from data
|
|
174
|
+
already present in the response (`voters` arrays, already used for the viewer's-own-vote
|
|
175
|
+
marking). `percent` now divides by `respondentCount`.
|
|
176
|
+
|
|
177
|
+
### Scope D — `Composer.jsx` emoji picker
|
|
178
|
+
New `COMPOSER_EMOJIS` (28 entries) defined locally in `Composer.jsx`, rendered as a
|
|
179
|
+
7-column CSS grid of `IconButton`s inside the existing `Menu` (replacing the old
|
|
180
|
+
`QUICK_EMOJIS.map(... <MenuItem>...)` vertical list — `MenuItem` import removed, now
|
|
181
|
+
unused). `ReactionBar.jsx`'s own `QUICK_EMOJIS` (5, curated) untouched.
|
|
182
|
+
|
|
183
|
+
### Companion fix found and fixed — `Thread.jsx` (not in this WO's original scope)
|
|
184
|
+
Discovered while live-verifying jg-ferien's `MSG-18` (mobile Composer hidden behind the
|
|
185
|
+
fixed bottom nav) in the *same* browser session as this WO's own work: after fixing every
|
|
186
|
+
jg-side layout offset, the Composer was **still** rendering behind the nav. Root cause
|
|
187
|
+
traced into this package: `Thread.jsx`'s root `<Stack>` had no `flex`/`minHeight`, and its
|
|
188
|
+
scroll `Box` used a **hardcoded `maxHeight: 560`** — neither participates in a host's
|
|
189
|
+
flex-column layout at all, so on a short mobile viewport the thread never shrinks to fit,
|
|
190
|
+
regardless of how correctly the *host* sizes its own containers. Fixed: both the root
|
|
191
|
+
`Stack` and the scroll `Box` gained `flex: 1, minHeight: 0` (the classic flex-item
|
|
192
|
+
`min-height: auto` trap — a flex item won't shrink below its content's natural size
|
|
193
|
+
without this). `maxHeight: 560` kept as an upper-bound fallback, so a host that does
|
|
194
|
+
**not** embed `Thread` in a flex-column context sees **zero behaviour change** (both new
|
|
195
|
+
properties are no-ops outside a flex context). Live-verified end-to-end (see MSG-18's own
|
|
196
|
+
as-built record in jg-ferien for the full trace): the composer moved from `top: 742`
|
|
197
|
+
(inside the fixed nav's own `740–812` range) to `top: 629.5` (a clean 64px gap above the
|
|
198
|
+
nav at `740`), by locally building this package's `dist/` and patching it into jg's
|
|
199
|
+
`node_modules` for verification — no publish needed to prove the fix, but IS needed to
|
|
200
|
+
actually ship it (bundled into this same release, not deferred).
|
|
201
|
+
|
|
202
|
+
### Tests
|
|
203
|
+
`tests/messagingMsg6i.test.jsx` (new) — 7 tests: all 5 WO-required cases, one extra
|
|
204
|
+
(emoji-insertion behaviour still works from the new grid), plus a 6th added post-review
|
|
205
|
+
(see R3 below) asserting `Thread.jsx`'s `flex`/`minHeight` are actually applied.
|
|
206
|
+
|
|
207
|
+
### Verification (pre-review)
|
|
208
|
+
Full suite 249 → 255 (6 new), `tsc` clean.
|
|
209
|
+
|
|
210
|
+
### Independent review — `reviewer` + `ui_reviewer`, both run in parallel, findings + fixes
|
|
211
|
+
|
|
212
|
+
Both reviewers independently converged on the same P2 keyboard-navigation regression
|
|
213
|
+
(scope D) — treated as one finding below (R1/U1).
|
|
214
|
+
|
|
215
|
+
- **R1/U1 (P2, confirmed by both reviewers) — Composer's emoji grid broke MUI `Menu`'s
|
|
216
|
+
arrow-key roving-focus.** Nesting the 28 `IconButton`s inside an intervening
|
|
217
|
+
`<Box role="group">` meant `MenuList`'s `moveFocus` (which only traverses **direct**
|
|
218
|
+
children) saw a single non-tabbable child, so ArrowDown/Up/Home/End all became dead —
|
|
219
|
+
none of the 28 buttons reachable via the menu's own keyboard model, and nesting
|
|
220
|
+
`role="button"` descendants under `role="menu"` isn't a conformant ARIA pattern either.
|
|
221
|
+
**Fixed**: swapped `<Menu>` for `<Popover>` (`Composer.jsx`) — a grid of plain buttons
|
|
222
|
+
was never actually a linear menu, so this stops advertising semantics it can't deliver
|
|
223
|
+
and leaves keyboard traversal to the browser's native Tab order, which does reach every
|
|
224
|
+
button. Replicated Menu's default `anchorOrigin`/`transformOrigin`
|
|
225
|
+
(`{vertical:'bottom',horizontal:'left'}` / `{vertical:'top',horizontal:'left'}`) so the
|
|
226
|
+
picker's on-screen position is unchanged.
|
|
227
|
+
- **R2 (P2, `reviewer`) — optimistic vote could desync `vote_count` from `voters`.**
|
|
228
|
+
`MessagingProvider.applyOptimisticVote` bumped `vote_count` unconditionally but only
|
|
229
|
+
updated `voters` when `userId` was known (a documented "brief window" before
|
|
230
|
+
`AuthContext` populates `user` — in practice also the default state for any host/test
|
|
231
|
+
that doesn't wrap `MessagingProvider` in `AuthContext` at all, confirmed while fixing
|
|
232
|
+
this). Scope C's new `respondentCount` denominator reads only from `voters`, so that
|
|
233
|
+
window could show a transiently inconsistent/>-100% percentage. **Fixed**: added a
|
|
234
|
+
sentinel (`OPTIMISTIC_ANONYMOUS_VOTER`, a space-prefixed string no real UUID can match)
|
|
235
|
+
used in place of `userId` in `voters` when it's unknown, so `vote_count` and `voters`
|
|
236
|
+
move together in every case — the real vote still overwrites this on
|
|
237
|
+
reconcile/revert. Re-ran every existing poll-vote test
|
|
238
|
+
(`messagingMsg6g/Interactions/PollRendering/CompactBubble/Api`) to confirm the
|
|
239
|
+
already-tested optimistic-count behaviour (which relies on the no-`AuthContext` test
|
|
240
|
+
shape) still holds — it does.
|
|
241
|
+
- **R3 (P2, `reviewer`) — `Thread.jsx`'s flex/minHeight companion fix had zero regression
|
|
242
|
+
coverage**, and the "not meaningfully assertable via jsdom" justification didn't hold up
|
|
243
|
+
(this WO's own tile-size test already proves resolved `sx` values ARE readable via
|
|
244
|
+
`getComputedStyle` without real layout). **Fixed**: added test 6 to
|
|
245
|
+
`tests/messagingMsg6i.test.jsx`, asserting `flexGrow`/`minHeight` on both `Thread`'s
|
|
246
|
+
root `Stack` and scroll `Box`, plus that `maxHeight: 560` is still present as the
|
|
247
|
+
fallback cap.
|
|
248
|
+
- **U2 (P3, `ui_reviewer`) — hardcoded `fontSize: '1.15rem'`** on the per-emoji
|
|
249
|
+
`IconButton`. No existing theme/typography token covers emoji-glyph sizing (checked
|
|
250
|
+
`ReactionBar.jsx`'s own emoji rendering for precedent — none exists there either).
|
|
251
|
+
**Not fixed** — P3, no clear correct token to switch to; noted here rather than
|
|
252
|
+
silently dropped.
|
|
253
|
+
- **U3 (P3, `ui_reviewer`) — the emoji grid reused `MessagingComposer.ADD_EMOJI`** as its
|
|
254
|
+
`role="group"` `aria-label`, identical to the trigger button's own label, so a screen
|
|
255
|
+
reader announces "Add emoji" twice back-to-back. **Fixed**: added a dedicated
|
|
256
|
+
`MessagingComposer.EMOJI_PICKER` key ("Choose an emoji" / "Emoji auswählen" / "Choisir
|
|
257
|
+
un emoji") for the grid.
|
|
258
|
+
- **R4 (P3, `reviewer`) — test 4b's assertion (`input.value.length > 2`) didn't prove
|
|
259
|
+
cursor-position insertion**, only that something got appended. **Fixed**: rewrote it to
|
|
260
|
+
match the exact expected string at a mid-string cursor position (same shape as the
|
|
261
|
+
pre-existing `messagingComposerChunk5.test.jsx` assertion).
|
|
262
|
+
- **R5 (P3, `reviewer`) — residual, not fixed**: the 120px tile stretches whatever
|
|
263
|
+
`getAttachmentThumbnail` returns via `objectFit: cover`; if that thumbnail is
|
|
264
|
+
server-sized for the old 64px target, the larger tile could show blur. Not verifiable
|
|
265
|
+
from this repo (backend-controlled, untouched by this WO) — flagged as disclosed
|
|
266
|
+
residual risk, consistent with scope B's own "no live screenshot check this round" note.
|
|
267
|
+
|
|
268
|
+
### Verification (post-review)
|
|
269
|
+
Full suite (single-forked to avoid an unrelated vitest worker-pool timeout seen under the
|
|
270
|
+
default parallel pool): **45 test files, 256/256 passed** (255 + 1 new). `tsc -p
|
|
271
|
+
tsconfig.build.json --noEmit` clean.
|