Add better preview pane and tweak some themes for contrast

This commit is contained in:
Jack Kingsman
2026-03-12 00:06:33 -07:00
parent 6466a5c355
commit 1f2903fc2d
8 changed files with 202 additions and 86 deletions

View File

@@ -94,15 +94,23 @@ export function ChatHeader({
onSetChannelFloodScopeOverride(conversation.id, nextValue);
};
const handleOpenConversationInfo = () => {
if (conversation.type === 'contact' && onOpenContactInfo) {
onOpenContactInfo(conversation.id);
return;
}
if (conversation.type === 'channel' && onOpenChannelInfo) {
onOpenChannelInfo(conversation.id);
}
};
return (
<header className="conversation-header flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
<span className="flex min-w-0 flex-1 items-start gap-2">
{conversation.type === 'contact' && onOpenContactInfo && (
<span
className="flex-shrink-0 cursor-pointer"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
<button
type="button"
className="avatar-action-button flex-shrink-0 cursor-pointer rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onOpenContactInfo(conversation.id)}
title="View contact info"
aria-label={`View info for ${conversation.name}`}
@@ -114,42 +122,41 @@ export function ChatHeader({
contactType={contacts.find((c) => c.public_key === conversation.id)?.type}
clickable
/>
</span>
</button>
)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<h2
className={`flex shrink min-w-0 items-center gap-1.5 font-semibold text-base ${titleClickable ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
role={titleClickable ? 'button' : undefined}
tabIndex={titleClickable ? 0 : undefined}
aria-label={titleClickable ? `View info for ${conversation.name}` : undefined}
onKeyDown={titleClickable ? handleKeyboardActivate : undefined}
onClick={
titleClickable
? () => {
if (conversation.type === 'contact' && onOpenContactInfo) {
onOpenContactInfo(conversation.id);
} else if (conversation.type === 'channel' && onOpenChannelInfo) {
onOpenChannelInfo(conversation.id);
}
}
: undefined
}
>
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
{titleClickable && (
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
<h2 className="min-w-0 flex-1 font-semibold text-base">
{titleClickable ? (
<button
type="button"
className="flex min-w-0 shrink items-center gap-1.5 text-left hover:text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
aria-label={`View info for ${conversation.name}`}
onClick={handleOpenConversationInfo}
>
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
</button>
) : (
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
)}
</h2>
{isPrivateChannel && !showKey ? (

View File

@@ -606,6 +606,10 @@ export function MessageList({
(avatarName ? `name:${avatarName}` : `message:${msg.id}`);
}
}
const avatarActionLabel =
avatarName && avatarName !== 'Unknown'
? `View info for ${avatarName}`
: `View info for ${avatarKey.slice(0, 12)}`;
return (
<div
@@ -619,26 +623,33 @@ export function MessageList({
>
{!msg.outgoing && (
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar && avatarKey && (
<span
role={onOpenContactInfo ? 'button' : undefined}
tabIndex={onOpenContactInfo ? 0 : undefined}
onKeyDown={onOpenContactInfo ? handleKeyboardActivate : undefined}
onClick={
onOpenContactInfo
? () => onOpenContactInfo(avatarKey, msg.type === 'CHAN')
: undefined
}
>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
clickable={!!onOpenContactInfo}
variant={avatarVariant}
/>
</span>
)}
{showAvatar &&
avatarKey &&
(onOpenContactInfo ? (
<button
type="button"
className="avatar-action-button rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={avatarActionLabel}
onClick={() => onOpenContactInfo(avatarKey, msg.type === 'CHAN')}
>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
clickable
variant={avatarVariant}
/>
</button>
) : (
<span>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
variant={avatarVariant}
/>
</span>
))}
</div>
)}
<div

View File

@@ -5,9 +5,9 @@ import {
ChevronDown,
ChevronRight,
LockOpen,
Logs,
Map,
Search as SearchIcon,
Sparkles,
SquarePen,
Waypoints,
X,
@@ -533,7 +533,7 @@ export function Sidebar({
renderSidebarActionRow({
key: 'tool-raw',
active: isActive('raw', 'raw'),
icon: <Waypoints className="h-4 w-4" />,
icon: <Logs className="h-4 w-4" />,
label: 'Packet Feed',
onClick: () =>
handleSelectConversation({
@@ -557,7 +557,7 @@ export function Sidebar({
renderSidebarActionRow({
key: 'tool-visualizer',
active: isActive('visualizer', 'visualizer'),
icon: <Sparkles className="h-4 w-4" />,
icon: <Waypoints className="h-4 w-4" />,
label: 'Mesh Visualizer',
onClick: () =>
handleSelectConversation({

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { Logs, MessageSquare } from 'lucide-react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Separator } from '../ui/separator';
import { ContactAvatar } from '../ContactAvatar';
import {
captureLastViewedConversationFromHash,
getReopenLastConversationEnabled,
@@ -97,7 +99,7 @@ function ThemePreview({ className }: { className?: string }) {
return (
<div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}>
<p className="text-xs text-muted-foreground mb-3">
Preview alert and message contrast for the selected theme.
Preview alert, message, sidebar, and badge contrast for the selected theme.
</p>
<div className="space-y-2">
@@ -125,6 +127,42 @@ function ThemePreview({ className }: { className?: string }) {
text="Hi there! I'm using RemoteTerm."
/>
</div>
<div className="mt-4 rounded-md border border-border bg-background p-2">
<p className="mb-2 text-[11px] font-medium text-muted-foreground">Sidebar preview</p>
<div className="space-y-1">
<PreviewSidebarRow
active
leading={
<span
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 text-primary"
aria-hidden="true"
>
<Logs className="h-3.5 w-3.5" />
</span>
}
label="Packet Feed"
/>
<PreviewSidebarRow
leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />}
label="Alice"
badge={
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[10px] font-semibold text-badge-unread-foreground">
3
</span>
}
/>
<PreviewSidebarRow
leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />}
label="Mesh Ops"
badge={
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[10px] font-semibold text-badge-mention-foreground">
@2
</span>
}
/>
</div>
</div>
</div>
);
}
@@ -153,3 +191,34 @@ function PreviewMessage({
</div>
);
}
function PreviewSidebarRow({
leading,
label,
badge,
active = false,
}: {
leading: React.ReactNode;
label: string;
badge?: React.ReactNode;
active?: boolean;
}) {
return (
<div
className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
}`}
>
{leading}
<span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
{label}
</span>
{badge}
{!badge && (
<span className="text-muted-foreground" aria-hidden="true">
<MessageSquare className="h-3.5 w-3.5" />
</span>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ChatHeader } from '../components/ChatHeader';
@@ -94,6 +94,28 @@ describe('ChatHeader key visibility', () => {
expect(screen.queryByText('Show Key')).not.toBeInTheDocument();
});
it('renders the clickable conversation title as a real button inside the heading', () => {
const pubKey = '12'.repeat(32);
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Alice' };
const onOpenContactInfo = vi.fn();
render(
<ChatHeader
{...baseProps}
conversation={conversation}
channels={[]}
onOpenContactInfo={onOpenContactInfo}
/>
);
const heading = screen.getByRole('heading', { name: /alice/i });
const titleButton = within(heading).getByRole('button', { name: 'View info for Alice' });
expect(heading).toContainElement(titleButton);
fireEvent.click(titleButton);
expect(onOpenContactInfo).toHaveBeenCalledWith(pubKey);
});
it('copies key to clipboard when revealed key is clicked', async () => {
const key = 'FF'.repeat(16);
const channel = makeChannel(key, 'Priv', false);

View File

@@ -61,4 +61,23 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
});
it('gives clickable sender avatars an accessible label', () => {
render(
<MessageList
messages={[
createMessage({
text: 'garbled payload with no sender prefix',
sender_name: 'Alice',
sender_key: 'ab'.repeat(32),
}),
]}
contacts={[]}
loading={false}
onOpenContactInfo={() => {}}
/>
);
expect(screen.getByRole('button', { name: 'View info for Alice' })).toBeInTheDocument();
});
});

View File

@@ -296,24 +296,6 @@ describe('SettingsModal', () => {
expect(screen.queryByLabelText('Local label text')).not.toBeInTheDocument();
});
it('shows the theme contrast preview in local settings', () => {
renderModal();
openLocalSection();
expect(
screen.getByText('Preview alert and message contrast for the selected theme.')
).toBeInTheDocument();
expect(
screen.getByText('Connected preview: radio link healthy and syncing.')
).toBeInTheDocument();
expect(
screen.getByText('Warning preview: packet audit suggests missing history.')
).toBeInTheDocument();
expect(screen.getByText('Error preview: radio reconnect failed.')).toBeInTheDocument();
expect(screen.getByText('Hello, mesh!')).toBeInTheDocument();
expect(screen.getByText("Hi there! I'm using RemoteTerm.")).toBeInTheDocument();
});
it('lists the new Windows 95 and iPhone themes', () => {
renderModal();
openLocalSection();

View File

@@ -79,7 +79,7 @@
--msg-incoming: 0 0% 92%;
--status-connected: 142 75% 32%;
--status-disconnected: 0 0% 35%;
--warning: 45 100% 45%;
--warning: 45 100% 38%;
--warning-foreground: 0 0% 0%;
--success: 142 75% 28%;
--success-foreground: 0 0% 100%;
@@ -130,6 +130,12 @@
inset -1px -1px 0 hsl(0 0% 34%);
}
[data-theme='windows-95'] .avatar-action-button {
background: transparent;
padding: 0;
box-shadow: none;
}
[data-theme='windows-95'] button:active,
[data-theme='windows-95'] button:active {
box-shadow:
@@ -335,7 +341,7 @@
--accent: 150 14% 12%;
--accent-foreground: 120 8% 82%;
--destructive: 340 100% 59%;
--destructive-foreground: 0 0% 100%;
--destructive-foreground: 340 100% 6%;
--border: 135 30% 14%;
--input: 135 30% 14%;
--ring: 135 100% 50%;
@@ -374,7 +380,7 @@
--popover: 0 0% 8%;
--popover-foreground: 0 0% 100%;
--primary: 212 100% 62%;
--primary-foreground: 0 0% 100%;
--primary-foreground: 0 0% 0%;
--secondary: 0 0% 12%;
--secondary-foreground: 0 0% 92%;
--muted: 0 0% 10%;
@@ -382,7 +388,7 @@
--accent: 0 0% 14%;
--accent-foreground: 0 0% 100%;
--destructive: 355 100% 50%;
--destructive-foreground: 0 0% 100%;
--destructive-foreground: 0 0% 0%;
--border: 0 0% 22%;
--input: 0 0% 22%;
--ring: 212 100% 62%;
@@ -665,7 +671,7 @@
--accent: 205 46% 22%;
--accent-foreground: 42 33% 92%;
--destructive: 8 88% 61%;
--destructive-foreground: 0 0% 100%;
--destructive-foreground: 196 60% 9%;
--border: 191 34% 24%;
--input: 191 34% 24%;
--ring: 175 72% 49%;
@@ -766,7 +772,7 @@
--accent: 251 28% 24%;
--accent-foreground: 302 30% 93%;
--destructive: 9 88% 66%;
--destructive-foreground: 0 0% 100%;
--destructive-foreground: 258 38% 12%;
--border: 256 24% 28%;
--input: 256 24% 28%;
--ring: 325 100% 74%;