feat: dashboard UI/UX overhaul from full review

Shared foundation:
- New Modal component on native <dialog>.showModal() (focus trap, inert
  background, Escape, focus restore, scroll lock); all eight dialogs and
  both globalThis.confirm calls migrated to it
- Design tokens for success/warning/danger surfaces; pill danger variant;
  .sr-only utility; toast layering above dialog backdrops; not-allowed
  cursor for validity-disabled buttons

Conversations/attention: auto-scroll with jump-to-latest, per-conversation
draft persistence, suggestion undo, 30s auto-refresh with refresh controls,
unread emphasis and counts, blur-consistent share dialog, empty-accounts
guidance, collapsed user lookup, relative timestamps, danger pills for
critical items

Media/library/outreach: click-blurred-to-reveal, lightbox with arrow
navigation and captions, dirty-form preservation across refetches, upload
validation with thumbnail preview, save feedback, result counts with
load-more and clear-filters, ticking countdowns from a single clock

Overview/performance/operations/settings: mutation errors inside the
confirm dialog, live sending-window countdown, offline-aware loading
states, per-panel loading/error with retry, 7/30/90-day range toggle,
console scroll yielding with shown/retained counts, risky-save matching
by subreddit name instead of index, useBlocker unsaved-changes guard,
single-pass validation with focus-to-first-error, access panel JSON
validation and input reset

Tests: 806 -> 844 passing; both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Fable 5 committed 2026-08-10 09:07:42 +01:00
1 parent 82be197b79
commit e984bf20ac
46 files changed
+2462 -495

No files matched your search

+131 -6
View File
@@ -2,12 +2,14 @@
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { MemoryRouter } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js';
import { resetComposerDrafts } from '../../apps/dashboard/src/features/conversations/composer-drafts.js';
interface ChatFixtureOptions {
username: string;
@@ -81,9 +83,11 @@ function renderConversations() {
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<ConversationsPage />
</QueryClientProvider>,
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<ConversationsPage />
</QueryClientProvider>
</MemoryRouter>,
);
return queryClient;
}
@@ -92,6 +96,7 @@ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
resetComposerDrafts();
});
afterAll(() => server.close());
@@ -152,8 +157,7 @@ describe('ConversationsPage', () => {
);
expect(screen.getByText('2h ago')).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveClass('is-blurred');
expect(screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' })).toBeDisabled();
await user.click(screen.getByRole('button', { name: 'Reveal original image' }));
await user.click(screen.getByRole('button', { name: 'Reveal source image: Looking for a chat' }));
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).not.toHaveClass('is-blurred');
const previewTrigger = screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' });
await user.click(previewTrigger);
@@ -321,6 +325,7 @@ describe('ConversationsPage', () => {
);
const user = userEvent.setup();
renderConversations();
await user.click(screen.getByText('Processed user lookup'));
await user.type(screen.getByLabelText('Lookup Reddit username'), 'u/alice');
await user.click(screen.getByRole('button', { name: 'Look up user' }));
expect(await screen.findByRole('list', { name: 'Processed posts for u/alice' })).toHaveTextContent('A processed post');
@@ -438,4 +443,124 @@ describe('ConversationsPage', () => {
expect(message).toHaveValue('');
expect(startRequests).toBe(2);
});
it('explains how to enable the start-conversation form when no accounts are running', async () => {
server.use(
http.get('/api/conversations', () => HttpResponse.json([])),
http.get('/api/accounts', () => HttpResponse.json([])),
);
const user = userEvent.setup();
renderConversations();
await user.click(screen.getByRole('button', { name: 'New conversation' }));
expect(await screen.findByText(/No running accounts/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/settings');
expect(screen.queryByRole('button', { name: 'Start conversation' })).not.toBeInTheDocument();
});
it('shows the unread count and marks unread rows with state and time in the label', async () => {
installConversationList();
renderConversations();
const aliceButton = await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('1 unread')).toBeInTheDocument();
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Unread, .+ ago/);
expect(aliceButton.closest('li')).toHaveClass('is-unread');
expect(screen.getByRole('button', { name: /u\/bob/ }).closest('li')).not.toHaveClass('is-unread');
});
it('offers a jump-to-latest control when the operator scrolls up in the history', async () => {
installConversationList();
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
const history = await screen.findByRole('list', { name: 'Message history' });
expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument();
Object.defineProperty(history, 'scrollHeight', { configurable: true, value: 1000 });
Object.defineProperty(history, 'clientHeight', { configurable: true, value: 300 });
history.scrollTop = 100;
fireEvent.scroll(history);
const jumpButton = await screen.findByRole('button', { name: 'Jump to latest' });
await user.click(jumpButton);
expect(history.scrollTop).toBe(1000);
expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument();
});
it('preserves an unsent draft when switching away from and back to a conversation', async () => {
installConversationList();
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
await user.type(composer, 'Draft in progress');
await user.click(screen.getByRole('button', { name: 'Close conversation' }));
expect(screen.queryByRole('textbox', { name: 'Reply to u/alice' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
expect(await screen.findByRole('textbox', { name: 'Reply to u/alice' })).toHaveValue('Draft in progress');
});
it('lets the operator undo a suggestion that replaced a typed draft', async () => {
installConversationList();
server.use(
http.post('/api/conversations/room-alice/suggest-reply', () =>
HttpResponse.json({ suggestedReply: 'A generated suggestion' }),
),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
await user.type(composer, 'My own words');
expect(screen.getByText('12 / 10000')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Suggest reply' }));
await waitFor(() => expect(composer).toHaveValue('A generated suggestion'));
expect(screen.getByText('The suggestion replaced your draft.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Undo' }));
expect(composer).toHaveValue('My own words');
expect(screen.queryByRole('button', { name: 'Undo' })).not.toBeInTheDocument();
});
it('keeps the confirmation image blurred when saved images were re-blurred before review', async () => {
const asset = {
id: '22222222-2222-4222-8222-222222222222', tags: ['selfie'], safety: 'sfw',
classificationStatus: 'ready', description: 'A selfie', confidence: 0.95,
fileName: 'selfie.jpg', mimeType: 'image/jpeg', sizeBytes: 1024, width: 800, height: 1000,
enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
sendCount: 0, lastSentAt: null, urlPath: '/library-media/selfie.jpg',
};
server.use(
http.get('/api/conversations', () => HttpResponse.json([alice])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture())),
http.get('/api/conversations/room-alice/shareable-media', () => HttpResponse.json({
requestKind: 'selfie', assets: [asset], reason: null,
})),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await user.click(await screen.findByText('Share a saved image'));
expect(await screen.findByText('Reveal images to select one.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Reveal saved images' }));
expect(screen.queryByText('Reveal images to select one.')).not.toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: /Saved image tagged selfie/ }));
await user.click(screen.getByRole('button', { name: 'Blur saved images' }));
await user.click(screen.getByRole('button', { name: 'Review image share' }));
const dialog = screen.getByRole('dialog', { name: 'Share this saved image?' });
const dialogImage = within(dialog).getByRole('img', { name: 'Saved image tagged selfie' });
expect(dialogImage).toHaveClass('is-blurred');
await user.click(within(dialog).getByRole('button', { name: 'Reveal image' }));
expect(dialogImage).not.toHaveClass('is-blurred');
});
});