@micha.bigler/ui-core-micha 2.15.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +7 -0
  2. package/WORK_ORDERS.md +5 -1
  3. package/dev/entries.jsx +147 -0
  4. package/dev/fixtures.js +26 -0
  5. package/dev/main.jsx +56 -0
  6. package/dev/mockTransport.jsx +128 -0
  7. package/dist/i18n/messagingTranslations.js +81 -0
  8. package/dist/index.js +14 -0
  9. package/dist/messaging/AttachmentList.js +43 -0
  10. package/dist/messaging/Composer.js +120 -0
  11. package/dist/messaging/ConversationLaunchers.js +39 -0
  12. package/dist/messaging/ConversationList.js +75 -0
  13. package/dist/messaging/MessageBubble.js +17 -0
  14. package/dist/messaging/MessagingProvider.js +317 -0
  15. package/dist/messaging/MessagingScopeConfig.js +28 -0
  16. package/dist/messaging/PollCard.js +53 -0
  17. package/dist/messaging/ReactionBar.js +44 -0
  18. package/dist/messaging/ReadTicks.js +25 -0
  19. package/dist/messaging/Thread.js +73 -0
  20. package/dist/messaging/api.js +51 -0
  21. package/dist/notifications/NotificationsProvider.js +2 -2
  22. package/dist/notifications/realtime.js +21 -1
  23. package/docs/messaging-deviations.md +36 -0
  24. package/index.html +12 -0
  25. package/package.json +4 -1
  26. package/src/i18n/messagingTranslations.ts +81 -0
  27. package/src/index.js +15 -0
  28. package/src/messaging/AttachmentList.jsx +38 -0
  29. package/src/messaging/Composer.jsx +110 -0
  30. package/src/messaging/ConversationLaunchers.jsx +51 -0
  31. package/src/messaging/ConversationList.jsx +78 -0
  32. package/src/messaging/MessageBubble.jsx +37 -0
  33. package/src/messaging/MessagingProvider.jsx +323 -0
  34. package/src/messaging/MessagingScopeConfig.jsx +24 -0
  35. package/src/messaging/PollCard.jsx +36 -0
  36. package/src/messaging/ReactionBar.jsx +46 -0
  37. package/src/messaging/ReadTicks.jsx +23 -0
  38. package/src/messaging/Thread.jsx +71 -0
  39. package/src/messaging/api.js +47 -0
  40. package/src/notifications/NotificationsProvider.jsx +2 -2
  41. package/src/notifications/realtime.jsx +21 -1
  42. package/tests/ConversationList.test.jsx +66 -0
  43. package/tests/MessagingProvider.test.jsx +50 -0
  44. package/tests/Thread.test.jsx +100 -0
  45. package/tests/messagingApi.test.js +32 -0
  46. package/tests/messagingComposer.test.jsx +111 -0
  47. package/tests/messagingExports.test.js +20 -0
  48. package/tests/messagingInteractions.test.jsx +112 -0
  49. package/tests/realtime.test.jsx +27 -0
  50. package/vite.config.mjs +6 -0
  51. package/work-orders/DX-1.md +166 -0
  52. package/work-orders/MSG-3.md +375 -0
@@ -0,0 +1,111 @@
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?.message ? `${key}:${options.message}` : (options?.sender ? `${key}:${options.sender}` : 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 { AuthContext } from '../src/auth/AuthContext';
18
+ import { AttachmentList } from '../src/messaging/AttachmentList';
19
+ import { Composer } from '../src/messaging/Composer';
20
+ import { messagingReducer, MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
21
+
22
+ function State() { const { cache } = useMessaging(); return <output data-testid="cache">{JSON.stringify(cache)}</output>; }
23
+ function makeApi() {
24
+ return {
25
+ listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
26
+ createMessage: vi.fn(), uploadAttachments: vi.fn(), getAttachment: vi.fn().mockResolvedValue(new Blob(['file'])), getAttachmentThumbnail: vi.fn().mockResolvedValue(new Blob(['image'], { type: 'image/png' })),
27
+ };
28
+ }
29
+ function renderComposer(api, { userId } = {}) {
30
+ const tree = <MessagingProvider api={api} active={false}><Composer conversationId={7} /><State /></MessagingProvider>;
31
+ return render(userId ? <AuthContext.Provider value={{ user: { id: userId, username: 'me' } }}>{tree}</AuthContext.Provider> : tree);
32
+ }
33
+ function typeAndSend(text = 'hello') { fireEvent.change(screen.getByLabelText('MessagingComposer.MESSAGE'), { target: { value: text } }); fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.SEND' })); }
34
+ const state = () => JSON.parse(screen.getByTestId('cache').textContent);
35
+
36
+ afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
37
+
38
+ describe('Composer optimistic send and attachments', () => {
39
+ it('reconciles REST then websocket confirmation into exactly one row', () => {
40
+ const pending = { id: 'local-r1', conversation_id: 7, client_request_id: 'r1', status: 'pending' };
41
+ const rest = { id: 88, conversation_id: 7, body: 'hello', client_request_id: 'r1' };
42
+ let next = messagingReducer({ conversations: {}, messages: {}, cursors: { conversations: null, messages: {}, threads: {} } }, { type: 'messageOptimistic', message: pending });
43
+ next = messagingReducer(next, { type: 'messageReconciled', message: rest });
44
+ next = messagingReducer(next, { type: 'frame', frame: { type: 'message', conversation_id: 7, message: rest } });
45
+ expect(Object.values(next.messages)).toHaveLength(1);
46
+ expect(next.messages[88].client_request_id).toBe('r1');
47
+ });
48
+
49
+ it('reconciles websocket then REST confirmation into exactly one row', () => {
50
+ const pending = { id: 'local-r2', conversation_id: 7, client_request_id: 'r2', status: 'pending' };
51
+ const websocket = { id: 89, conversation_id: 7, body: 'hello', client_request_id: 'r2' };
52
+ let next = messagingReducer({ conversations: {}, messages: {}, cursors: { conversations: null, messages: {}, threads: {} } }, { type: 'messageOptimistic', message: pending });
53
+ next = messagingReducer(next, { type: 'frame', frame: { type: 'message', conversation_id: 7, message: websocket } });
54
+ next = messagingReducer(next, { type: 'messageReconciled', message: websocket });
55
+ expect(Object.values(next.messages)).toHaveLength(1);
56
+ expect(next.messages[89].client_request_id).toBe('r2');
57
+ });
58
+
59
+ it('retries a failed send with the same idempotency key and optimistic row', async () => {
60
+ const api = makeApi(); api.createMessage.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ id: 90, conversation_id: 7, body: 'hello' });
61
+ renderComposer(api); typeAndSend(); await screen.findByRole('alert'); const first = api.createMessage.mock.calls[0][1].client_request_id;
62
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.RETRY' }));
63
+ await waitFor(() => expect(api.createMessage).toHaveBeenCalledTimes(2));
64
+ expect(api.createMessage.mock.calls[1][1].client_request_id).toBe(first);
65
+ await waitFor(() => expect(Object.keys(state().messages)).toEqual(['90']));
66
+ });
67
+
68
+ it('shows the server validation message for a rejected attachment upload', async () => {
69
+ // dcm's ConversationAttachmentView translates a rejected upload into a
70
+ // field-keyed {"files": [...]} body (the MSG-2 chunk-4 fix), not {detail}.
71
+ const api = makeApi(); api.uploadAttachments.mockRejectedValue({ response: { data: { files: ['File type application/octet-stream is not allowed.'] } } });
72
+ renderComposer(api); const input = document.querySelector('input[type=file]'); const file = new File(['x'], 'bad.exe', { type: 'application/octet-stream' });
73
+ fireEvent.change(input, { target: { files: [file] } }); fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.SEND' }));
74
+ await screen.findByText('MessagingComposer.UPLOAD_ERROR:File type application/octet-stream is not allowed.');
75
+ });
76
+
77
+ it('shows the server validation message when it arrives as {detail} (the text-message error shape)', async () => {
78
+ const api = makeApi(); api.createMessage.mockRejectedValue({ response: { data: { detail: 'Posting is not permitted.' } } });
79
+ renderComposer(api); typeAndSend('hello');
80
+ await screen.findByText('MessagingComposer.SEND_ERROR:Posting is not permitted.');
81
+ });
82
+
83
+ it('populates the optimistic row with the current user as sender, not "unknown sender"', async () => {
84
+ let resolveCreate; const api = makeApi(); api.createMessage.mockReturnValue(new Promise((resolve) => { resolveCreate = resolve; }));
85
+ renderComposer(api, { userId: 9 }); typeAndSend('hello');
86
+ await waitFor(() => expect(Object.keys(state().messages)).toHaveLength(1));
87
+ const [optimistic] = Object.values(state().messages);
88
+ expect(optimistic.status).toBe('pending');
89
+ expect(optimistic.sender).toEqual({ id: 9, display_name: 'me' });
90
+ resolveCreate({ id: 91, conversation_id: 7, body: 'hello' });
91
+ });
92
+
93
+ it('end-to-end: a real in-flight send reconciled by a WS frame arriving before the REST response settles produces exactly one row', async () => {
94
+ let resolveCreate; const api = makeApi(); api.createMessage.mockReturnValue(new Promise((resolve) => { resolveCreate = resolve; }));
95
+ renderComposer(api); typeAndSend('hello');
96
+ await waitFor(() => expect(Object.keys(state().messages)).toHaveLength(1));
97
+ const requestId = api.createMessage.mock.calls[0][1].client_request_id;
98
+ // The WS frame for this message's creation arrives while the REST POST is still in flight.
99
+ act(() => dispatchMessagingFrame({ envelope: 'messaging', type: 'message', conversation_id: 7, message: { id: 91, conversation_id: 7, body: 'hello', client_request_id: requestId } }));
100
+ expect(Object.keys(state().messages)).toEqual(['91']);
101
+ // The REST response for the same send resolves afterward and must not create a second row.
102
+ await act(async () => resolveCreate({ id: 91, conversation_id: 7, body: 'hello', client_request_id: requestId }));
103
+ expect(Object.keys(state().messages)).toEqual(['91']);
104
+ });
105
+
106
+ it('mounts Composer and AttachmentList independently', async () => {
107
+ const api = makeApi(); const { unmount } = renderComposer(api); expect(screen.getByLabelText('MessagingComposer.LABEL')).toBeTruthy(); unmount();
108
+ render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[{ id: 1, filename: 'note.pdf', content_type: 'application/pdf' }]} /></MessagingProvider>);
109
+ expect(screen.getByLabelText('MessagingAttachments.LABEL')).toBeTruthy();
110
+ });
111
+ });
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { AttachmentList, Composer, ConversationLaunchers, ConversationList, MessageBubble, MessagingProvider, MessagingScopeConfig, messagingTranslations, PollCard, ReactionBar, ReadTicks, Thread, useMessaging } from '../src/index';
3
+
4
+ describe('messaging barrel exports', () => {
5
+ it('exports the provider, hook, standalone list and launchers', () => {
6
+ expect(MessagingProvider).toBeDefined();
7
+ expect(useMessaging).toBeDefined();
8
+ expect(ConversationList).toBeDefined();
9
+ expect(ConversationLaunchers).toBeDefined();
10
+ expect(Thread).toBeDefined();
11
+ expect(MessageBubble).toBeDefined();
12
+ expect(ReadTicks).toBeDefined();
13
+ expect(Composer).toBeDefined();
14
+ expect(AttachmentList).toBeDefined();
15
+ expect(ReactionBar).toBeDefined();
16
+ expect(PollCard).toBeDefined();
17
+ expect(MessagingScopeConfig).toBeDefined();
18
+ expect(messagingTranslations).toBeDefined();
19
+ });
20
+ });
@@ -0,0 +1,112 @@
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?.message ? `${key}:${options.message}` : key }) }));
7
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
8
+
9
+ import { Composer } from '../src/messaging/Composer';
10
+ import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
11
+ import { PollCard } from '../src/messaging/PollCard';
12
+ import { ReactionBar } from '../src/messaging/ReactionBar';
13
+ import { MessagingScopeConfig } from '../src/messaging/MessagingScopeConfig';
14
+
15
+ function CacheState() { const { cache } = useMessaging(); return <output data-testid="cache">{JSON.stringify(cache)}</output>; }
16
+
17
+ const baseApi = () => ({ listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }), addReaction: vi.fn(), removeReaction: vi.fn(), votePoll: vi.fn(), closePoll: vi.fn(), createPoll: vi.fn() });
18
+ afterEach(cleanup);
19
+
20
+ describe('messaging reactions and polls', () => {
21
+ it('round-trips add and remove reactions through the cache-backed API', async () => {
22
+ const api = baseApi(); api.addReaction.mockResolvedValue({ reactions: [{ emoji: '👍', count: 1, reacted: true }] }); api.removeReaction.mockResolvedValue({ reactions: [] });
23
+ const { rerender } = render(<MessagingProvider api={api} active={false}><ReactionBar message={{ id: 7, reactions: [{ emoji: '👍', count: 1, reacted: false }] }} /></MessagingProvider>);
24
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE' })); await waitFor(() => expect(api.addReaction).toHaveBeenCalledWith(7, '👍'));
25
+ rerender(<MessagingProvider api={api} active={false}><ReactionBar message={{ id: 7, reactions: [{ emoji: '👍', count: 1, reacted: true }] }} /></MessagingProvider>);
26
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE' })); await waitFor(() => expect(api.removeReaction).toHaveBeenCalledWith(7, '👍'));
27
+ });
28
+
29
+ it('uses single selection for a single poll and preserves selections for a multi poll', async () => {
30
+ const single = baseApi(); single.votePoll.mockResolvedValue({ poll: { id: 1, options: [] } });
31
+ 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>);
32
+ 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();
33
+ 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>);
34
+ 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]));
35
+ });
36
+
37
+ it('only exposes close to the creator/capability and surfaces a forbidden close', async () => {
38
+ const api = baseApi(); api.closePoll.mockRejectedValue({ response: { data: { detail: 'Not permitted' } } });
39
+ const { rerender } = render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 7, poll: { id: 1, question: 'Q', options: [], created_by: 2 }}} /></MessagingProvider>);
40
+ expect(screen.queryByRole('button', { name: 'MessagingPoll.CLOSE' })).toBeNull();
41
+ rerender(<MessagingProvider api={api} active={false}><PollCard canClose message={{ id: 7, poll: { id: 1, question: 'Q', options: [] }}} /></MessagingProvider>);
42
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingPoll.CLOSE' })); await screen.findByRole('alert');
43
+ });
44
+
45
+ it('creates polls with an idempotency key', async () => {
46
+ const api = baseApi(); api.createPoll.mockResolvedValue({ id: 70, conversation_id: 4, poll: { id: 1 } });
47
+ render(<MessagingProvider api={api} active={false}><Composer conversationId={4} /></MessagingProvider>);
48
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingPoll.CREATE' })); fireEvent.change(screen.getByLabelText('MessagingPoll.QUESTION'), { target: { value: 'Where?' } });
49
+ const options = screen.getAllByLabelText(/MessagingPoll.OPTION/); fireEvent.change(options[0], { target: { value: 'Here' } }); fireEvent.change(options[1], { target: { value: 'There' } });
50
+ fireEvent.click(screen.getAllByRole('button', { name: 'MessagingPoll.CREATE' })[0]); await waitFor(() => expect(api.createPoll).toHaveBeenCalled());
51
+ expect(api.createPoll.mock.calls[0][2].idempotencyKey).toBeTruthy();
52
+ });
53
+
54
+ it('mounts the scope config as an independent provider collaborator', async () => {
55
+ const api = baseApi(); api.getConversationConfig = vi.fn().mockResolvedValue({ dm_policy: 'all', group_chat_enabled: true, everyone_can_post: false });
56
+ render(<MessagingProvider api={api} active={false}><MessagingScopeConfig conversationId={4} /></MessagingProvider>);
57
+ await screen.findByLabelText('MessagingConfig.LABEL');
58
+ expect(api.getConversationConfig).toHaveBeenCalledWith(4);
59
+ });
60
+
61
+ it('creates an announcement with a title, body and host-supplied link target', async () => {
62
+ const api = baseApi(); api.createMessage = vi.fn().mockResolvedValue({ id: 90, conversation_id: 4, kind: 'announcement', title: 'Heads up' });
63
+ render(<MessagingProvider api={api} active={false}><Composer conversationId={4} allowAnnouncement linkTarget="/events/4/info" /></MessagingProvider>);
64
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingAnnouncement.CREATE' }));
65
+ fireEvent.change(screen.getByLabelText('MessagingAnnouncement.TITLE'), { target: { value: 'Heads up' } });
66
+ fireEvent.change(screen.getByLabelText('MessagingAnnouncement.BODY'), { target: { value: 'Schedule changed.' } });
67
+ fireEvent.click(screen.getAllByRole('button', { name: 'MessagingAnnouncement.SEND' })[0]);
68
+ await waitFor(() => expect(api.createMessage).toHaveBeenCalled());
69
+ const [, payload] = api.createMessage.mock.calls[0];
70
+ expect(payload).toMatchObject({ kind: 'announcement', title: 'Heads up', body: 'Schedule changed.', link_target: '/events/4/info' });
71
+ });
72
+
73
+ it('carries title and link_target into the optimistic row, not just the final REST payload', async () => {
74
+ const api = baseApi(); api.createMessage = vi.fn().mockReturnValue(new Promise(() => {}));
75
+ render(<MessagingProvider api={api} active={false}><Composer conversationId={4} allowAnnouncement linkTarget="/events/4/info" /><CacheState /></MessagingProvider>);
76
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingAnnouncement.CREATE' }));
77
+ fireEvent.change(screen.getByLabelText('MessagingAnnouncement.TITLE'), { target: { value: 'Heads up' } });
78
+ fireEvent.click(screen.getAllByRole('button', { name: 'MessagingAnnouncement.SEND' })[0]);
79
+ await waitFor(() => {
80
+ const [optimistic] = Object.values(JSON.parse(screen.getByTestId('cache').textContent).messages);
81
+ expect(optimistic.status).toBe('pending');
82
+ expect(optimistic.title).toBe('Heads up');
83
+ expect(optimistic.link_target).toBe('/events/4/info');
84
+ });
85
+ });
86
+
87
+ it('does not offer announcement composing unless the host opts in', () => {
88
+ const api = baseApi();
89
+ render(<MessagingProvider api={api} active={false}><Composer conversationId={4} /></MessagingProvider>);
90
+ expect(screen.queryByRole('button', { name: 'MessagingAnnouncement.CREATE' })).toBeNull();
91
+ });
92
+
93
+ it('disables the chip while pending, then rolls back and re-enables it on API failure', async () => {
94
+ let rejectAdd; const api = baseApi(); api.addReaction.mockReturnValue(new Promise((_, reject) => { rejectAdd = reject; }));
95
+ render(<MessagingProvider api={api} active={false}><ReactionBar message={{ id: 7, reactions: [{ emoji: '👍', count: 1, reacted: false }] }} /></MessagingProvider>);
96
+ const chip = screen.getByRole('button', { name: 'MessagingReactions.TOGGLE' });
97
+ fireEvent.click(chip);
98
+ await waitFor(() => expect(chip.className.includes('Mui-disabled')).toBe(true));
99
+ rejectAdd({ response: { data: { detail: 'Reactions disabled' } } });
100
+ await screen.findByRole('alert');
101
+ expect(chip.className.includes('Mui-disabled')).toBe(false);
102
+ });
103
+
104
+ it('ignores a second click on the same reaction while the first toggle is still in flight', async () => {
105
+ let resolveAdd; const api = baseApi(); api.addReaction.mockReturnValue(new Promise((resolve) => { resolveAdd = resolve; }));
106
+ render(<MessagingProvider api={api} active={false}><ReactionBar message={{ id: 7, reactions: [{ emoji: '👍', count: 1, reacted: false }] }} /></MessagingProvider>);
107
+ const chip = screen.getByRole('button', { name: 'MessagingReactions.TOGGLE' });
108
+ fireEvent.click(chip); fireEvent.click(chip);
109
+ resolveAdd({ reactions: [{ emoji: '👍', count: 2, reacted: true }] });
110
+ await waitFor(() => expect(api.addReaction).toHaveBeenCalledTimes(1));
111
+ });
112
+ });
@@ -37,6 +37,13 @@ function StreamConsumer({ envelope, testId }) {
37
37
  return <output data-testid={testId}>{JSON.stringify(received)}</output>;
38
38
  }
39
39
 
40
+ function ReconnectConsumer() {
41
+ const { onReconnect } = useRealtime();
42
+ const [count, setCount] = React.useState(0);
43
+ React.useEffect(() => onReconnect(() => setCount((current) => current + 1)), [onReconnect]);
44
+ return <output data-testid="reconnect-count">{count}</output>;
45
+ }
46
+
40
47
  function NotificationsConsumer() {
41
48
  const notifications = useNotifications();
42
49
  return (
@@ -181,6 +188,26 @@ describe('realtime primitive (NOTIF-13)', () => {
181
188
  }
182
189
  });
183
190
 
191
+ it('additively signals consumers when a closed connection re-establishes', async () => {
192
+ vi.useFakeTimers();
193
+ try {
194
+ render(
195
+ <AuthContext.Provider value={{ user: { id: 4 } }}>
196
+ <NotificationsProvider><ReconnectConsumer /></NotificationsProvider>
197
+ </AuthContext.Provider>,
198
+ );
199
+ await vi.waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
200
+ MockWebSocket.instances[0].onopen();
201
+ expect(screen.getByTestId('reconnect-count').textContent).toBe('0');
202
+ MockWebSocket.instances[0].onclose();
203
+ await vi.advanceTimersByTimeAsync(1_000);
204
+ MockWebSocket.instances[1].onopen();
205
+ await vi.waitFor(() => expect(screen.getByTestId('reconnect-count').textContent).toBe('1'));
206
+ } finally {
207
+ vi.useRealTimers();
208
+ }
209
+ });
210
+
184
211
  it("NotificationsProvider's public context value is unchanged", async () => {
185
212
  renderWithStreams();
186
213
  await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
@@ -0,0 +1,6 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ });
@@ -0,0 +1,166 @@
1
+ # DX-1 — minimal Vite dev harness for ucm components
2
+
3
+ Status: planned · Tier 1 (dev-only tooling; **adds devDependencies → approval-gated**) · Target repo: `ui-core-micha` (main)
4
+
5
+ ---
6
+
7
+ ## Part A — Envelope (Expertenchat, 2026-07-31)
8
+
9
+ ### Goal
10
+
11
+ Give `ui-core-micha` a way to **render** its components during development. Today `package.json`
12
+ exposes only `build` (tsc) and `test` (vitest): there is no Storybook, no demo app, no dev page. Every
13
+ shipped surface — auth, onboarding, notifications, charts — has only ever been verified in jsdom or
14
+ after a consuming app pinned it.
15
+
16
+ Immediate driver: MSG-3 is ~3700 LOC of new UI (jg's parity target: `Thread.jsx` alone is 2470 LOC)
17
+ that no app mounts until MSG-5, and the operator granted a redesign licence, which is only iterable
18
+ if the result can be looked at. But the gap is not messaging-specific and the harness is not
19
+ messaging-specific.
20
+
21
+ ### Expected outcome
22
+
23
+ - A `pnpm dev` (or equivalent) script starting a Vite dev server that mounts ucm components in a
24
+ browser, with the repo's MUI theme, `react-i18next` and router context wired the way a host app
25
+ wires them.
26
+ - A small set of harness entries covering the existing surfaces well enough to prove the harness is
27
+ real: at minimum one notifications surface and one charts surface. Adding an entry for a new
28
+ component must be a few lines, not a framework.
29
+ - **A single component must be mountable standalone**, not only a whole assembled surface. MSG-3
30
+ relies on this: it requires one harness entry per named component as its decomposition forcing
31
+ function, so the harness must not assume an entry equals a full page.
32
+ - A **mock transport layer** the entries drive: fixture data plus an injectable API/realtime adapter,
33
+ so a surface can be exercised without a backend. Where a component takes its data through a
34
+ provider, the harness supplies a mock provider rather than patching the component.
35
+ - Light/dark theme and viewport switching, since responsive and dark-mode correctness is exactly what
36
+ jsdom cannot show.
37
+ - Harness code lives outside the published package: not exported from `src/index.js`, excluded from
38
+ `tsconfig.build.json`, and adding **zero** runtime dependencies. `dist/` must be byte-identical in
39
+ content to a build from before this WO.
40
+ - `README` (or `CONTRIBUTING`) gains a short section on running the harness and adding an entry.
41
+
42
+ ### Non-goals / do-not-touch
43
+
44
+ Storybook (deliberately not chosen — a dev page is a fraction of the config surface and this repo has
45
+ no story-writing habit to build on; revisit only if the harness proves insufficient); visual
46
+ regression / screenshot testing; publishing the harness; any change to component source, exports, or
47
+ public API; any change to the existing vitest setup; CI wiring (the harness is a local dev tool, not
48
+ a gate).
49
+
50
+ ### Required tests to WRITE
51
+
52
+ This is dev tooling, so the meaningful assertions are about **not** affecting the product:
53
+
54
+ - The published barrel (`src/index.js`) is unchanged — the existing exports regression test
55
+ (`tests/notificationsExports.test.js` pattern) must still pass, and no harness module is reachable
56
+ from it.
57
+ - A build (`tsc -p tsconfig.build.json`) succeeds and emits no harness files into `dist/`.
58
+ - `package.json` `dependencies` and `peerDependencies` are unchanged; additions are confined to
59
+ `devDependencies`.
60
+
61
+ No component behaviour is under test here — the existing suite covers that and must stay green.
62
+
63
+ ### Risks
64
+
65
+ - **Dependency addition** is the real cost and the reason this is approval-gated: Vite plus a React
66
+ plugin. Keep it to the minimum; do not pull in a UI-catalog framework.
67
+ - **Harness fidelity.** A mock adapter can drift from the real REST/realtime contract and produce
68
+ confidence that does not transfer. Mitigation: fixtures are shaped from the actual dcm contract in
69
+ `django-core-micha/docs/design/messaging-platform.md` §REST/§Realtime, and the harness is a
70
+ development aid — it never replaces the tests or the independent review.
71
+ - Scope creep into "a component gallery". The deliverable is a dev page, not a product.
72
+
73
+ ### Preconditions
74
+
75
+ Operator approval for the devDependency additions — granted 2026-07-31 as part of the MSG-3 scoping
76
+ decision. This WO **runs before MSG-3**.
77
+
78
+ ### Execution directive
79
+
80
+ Implement through `codex exec` in the background — invoked **directly via Bash** (never the
81
+ `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check` and
82
+ `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from a file;
83
+ fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
84
+
85
+ ### Release
86
+
87
+ **No version bump, no publish.** Dev-only tooling changes nothing in the package. The next ucm
88
+ publish rides whatever WO follows (MSG-3).
89
+
90
+ ### Mini-handover (pastable)
91
+
92
+ Orchestrator: implement `work-orders/DX-1.md` in `ui-core-micha` (main). `git pull` first, read the
93
+ WO, then follow `orchestrate-codex` (Codex-first, own independent review, commit on green, **no
94
+ publish**).
95
+
96
+ ---
97
+
98
+ ## Part B — Implementation map (Orchestrator)
99
+
100
+ ### Target repo / working directory
101
+
102
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha` (repo root; package `@micha.bigler/ui-core-micha`,
103
+ current published version 2.15.0 — **unchanged by this WO**, no publish).
104
+
105
+ ### Context package
106
+
107
+ **Repo conventions to mirror:**
108
+ - `package.json` — current `scripts`: `build` (`tsc -p tsconfig.build.json`), `test` (`vitest run`).
109
+ No `dev` script exists yet; add one. `peerDependencies`/`dependencies` must not change;
110
+ `devDependencies` gains Vite + `@vitejs/plugin-react` only (or whatever minimal set a Vite+React+JSX
111
+ dev server needs) — no UI-catalog framework, no Storybook.
112
+ - `tsconfig.build.json` — `"include": ["src"]`, `rootDir: "./src"`, `outDir: "dist"`. The harness must
113
+ live **outside** `src/` (e.g. a top-level `dev/` directory) so it is never included by this config
114
+ and never emitted into `dist/`. Confirm after writing: `dist/` from a build with the harness present
115
+ must be byte-identical to a build from before this WO (no new/changed files under `dist/`).
116
+ - `src/index.js` — the published barrel. Do not add anything here for the harness itself; the harness
117
+ imports FROM the barrel (or directly from component source, orchestrator's/Codex's call — importing
118
+ from source is likely simpler and avoids a dist rebuild loop during dev) but nothing is added TO it.
119
+ - `tests/notificationsExports.test.js` — the existing exports-regression pattern to confirm still
120
+ passes untouched (proves the barrel didn't change).
121
+ - `src/notifications/realtime.jsx` — `useRealtimeCore({ active, wsUrl })` returns `{ subscribe }`;
122
+ `RealtimeContext` is the context the mock realtime provider must satisfy so components under it can
123
+ call `useRealtime()` without throwing. The harness's mock realtime adapter should provide a
124
+ `RealtimeContext.Provider` with an injectable `subscribe` (e.g. a manual event-dispatch harness) so
125
+ a harness entry can simulate a WS frame arriving, without opening a real socket.
126
+ - `src/notifications/NotificationsProvider.jsx` (read for the provider-wrapping pattern) — a
127
+ representative provider-consuming surface for the "at minimum one notifications surface" harness
128
+ entry requirement.
129
+ - `src/i18n/notificationsTranslations.ts` / `src/i18n/chartsTranslations.ts` — the flat i18n key
130
+ pattern; the harness needs `react-i18next` wired with at least these (or a minimal fixture set) so
131
+ mounted components render translated text instead of raw keys.
132
+ - Chart components (`ChartFrame`/`BarChart`/`LineChart`, per `tests/ChartFrame.test.jsx` etc.) — the
133
+ "at minimum one charts surface" harness entry.
134
+
135
+ **Invariants:**
136
+ - Zero new runtime dependencies (peer or direct) — Vite/plugin additions are dev-only.
137
+ - Harness is not exported, not built, not tested for behavior (only the three "does not affect the
138
+ product" assertions in Required Tests below).
139
+ - No change to the existing vitest test setup/config or any existing test file.
140
+ - No CI wiring — this is a local dev tool.
141
+
142
+ ### Required tests
143
+
144
+ Per envelope: extend/confirm the exports-regression test still passes with nothing new reachable from
145
+ it; a `build` succeeds and diffing `dist/` before/after shows no harness files and no content change;
146
+ inspect `package.json` to confirm `dependencies`/`peerDependencies` are byte-identical and only
147
+ `devDependencies` gained entries.
148
+
149
+ ### Progress contract
150
+
151
+ Narrate continuously: a `PLAN: <step1> | <step2> | …` line up front, then a single-line
152
+ `PROGRESS: [<n>/<total>] <present-tense action>` before every relevant action (file opened, file
153
+ edited, command/test run) and `PROGRESS: [<n>/<total>] done` on step completion, spaced so no gap
154
+ exceeds ~2 min, stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
155
+
156
+ ### Preamble (must be appended verbatim to the Codex prompt)
157
+
158
+ The text above is the COMPLETE spec — read nearest `AGENTS.md`, `.codex/skills/<role>/SKILL.md` (if
159
+ present), and this repo's `MEMORY.md` only for conventions; stay in scope; do not touch
160
+ `peerDependencies`/`dependencies`, only `devDependencies`; do not touch `src/index.js`'s exports; do
161
+ not update `MEMORY.md`; do NOT `git add`/`commit`/`push` — leave the change uncommitted in the working
162
+ tree for the orchestrator's independent review. WRITE and RUN the three required checks (exports test,
163
+ build + dist diff, package.json dependency diff) yourself to confirm — that is your only verification
164
+ run: do NOT run the full vitest suite as a "review" and do NOT run any review; the orchestrator does
165
+ both after you finish (though re-running `vitest run` in full is harmless and encouraged as a sanity
166
+ check since it's the existing test command, just don't treat it as satisfying the review step).