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