Accessibility overhaul

This commit is contained in:
Jack Kingsman
2026-03-02 20:34:06 -08:00
parent 7d39e726b4
commit fb279ccf1a
35 changed files with 348 additions and 107 deletions
+2 -2
View File
@@ -722,7 +722,7 @@ SOFTWARE.
</details>
### @uiw/react-codemirror (4.25.4) — MIT
### @uiw/react-codemirror (4.25.7) — MIT
*License file not found in package.*
@@ -1380,7 +1380,7 @@ SOFTWARE.
</details>
### tailwind-merge (3.4.0) — MIT
### tailwind-merge (3.5.0) — MIT
<details>
<summary>Full license text</summary>
+8 -4
View File
@@ -465,7 +465,10 @@ export function App() {
);
const settingsSidebarContent = (
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
<nav
className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col"
aria-label="Settings"
>
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
Settings
@@ -473,7 +476,7 @@ export function App() {
<button
type="button"
onClick={handleCloseSettingsView}
className="h-6 w-6 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
className="h-6 w-6 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title="Back to conversations"
aria-label="Back to conversations"
>
@@ -486,16 +489,17 @@ export function App() {
key={section}
type="button"
className={cn(
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors',
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
settingsSection === section && 'bg-accent border-l-primary'
)}
aria-current={settingsSection === section ? 'true' : undefined}
onClick={() => setSettingsSection(section)}
>
{SETTINGS_SECTION_LABELS[section]}
</button>
))}
</div>
</div>
</nav>
);
const activeSidebarContent = showSettings ? settingsSidebarContent : sidebarContent;
+30 -9
View File
@@ -1,5 +1,6 @@
import { toast } from './ui/sonner';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Contact, Conversation, Favorite, RadioConfig } from '../types';
@@ -28,11 +29,14 @@ export function ChatHeader({
onOpenContactInfo,
}: ChatHeaderProps) {
return (
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<span className="flex flex-wrap items-center gap-x-2 min-w-0 flex-1">
{conversation.type === 'contact' && onOpenContactInfo && (
<span
className="flex-shrink-0 cursor-pointer"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => onOpenContactInfo(conversation.id)}
title="View contact info"
>
@@ -45,8 +49,15 @@ export function ChatHeader({
/>
</span>
)}
<span
<h2
className={`flex-shrink-0 font-semibold text-base ${conversation.type === 'contact' && onOpenContactInfo ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
role={conversation.type === 'contact' && onOpenContactInfo ? 'button' : undefined}
tabIndex={conversation.type === 'contact' && onOpenContactInfo ? 0 : undefined}
onKeyDown={
conversation.type === 'contact' && onOpenContactInfo
? handleKeyboardActivate
: undefined
}
onClick={
conversation.type === 'contact' && onOpenContactInfo
? () => onOpenContactInfo(conversation.id)
@@ -59,9 +70,12 @@ export function ChatHeader({
? '#'
: ''}
{conversation.name}
</span>
</h2>
<span
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(conversation.id);
@@ -90,17 +104,18 @@ export function ChatHeader({
{/* Direct trace button (contacts only) */}
{conversation.type === 'contact' && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onTrace}
title="Direct Trace"
aria-label="Direct Trace"
>
&#x1F6CE;
<span aria-hidden="true">&#x1F6CE;</span>
</button>
)}
{/* Favorite button */}
{(conversation.type === 'channel' || conversation.type === 'contact') && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() =>
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
}
@@ -109,6 +124,11 @@ export function ChatHeader({
? 'Remove from favorites'
: 'Add to favorites'
}
aria-label={
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
? 'Remove from favorites'
: 'Add to favorites'
}
>
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
<span className="text-amber-400">&#9733;</span>
@@ -120,7 +140,7 @@ export function ChatHeader({
{/* Delete button */}
{!(conversation.type === 'channel' && conversation.name === 'Public') && (
<button
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors"
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
if (conversation.type === 'channel') {
onDeleteChannel(conversation.id);
@@ -129,11 +149,12 @@ export function ChatHeader({
}
}}
title="Delete"
aria-label="Delete"
>
&#128465;
<span aria-hidden="true">&#128465;</span>
</button>
)}
</div>
</div>
</header>
);
}
@@ -27,6 +27,7 @@ export function ContactAvatar({
height: size,
fontSize: size * 0.45,
}}
aria-hidden="true"
>
{avatar.text}
</div>
@@ -4,6 +4,7 @@ import { formatTime } from '../utils/messageParser';
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
@@ -110,6 +111,9 @@ export function ContactInfoPane({
</h2>
<span
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block truncate"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => {
navigator.clipboard.writeText(contact.public_key);
toast.success('Public key copied!');
@@ -167,6 +171,9 @@ export function ContactInfoPane({
<SectionLabel>Location</SectionLabel>
<span
className="text-sm font-mono cursor-pointer hover:text-primary hover:underline transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => {
const url =
window.location.origin +
@@ -256,6 +263,9 @@ export function ContactInfoPane({
? 'cursor-pointer hover:text-primary transition-colors truncate'
: 'truncate'
}
role={onNavigateToChannel ? 'button' : undefined}
tabIndex={onNavigateToChannel ? 0 : undefined}
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
onClick={() => onNavigateToChannel?.(room.channel_key)}
>
{room.channel_name.startsWith('#') || room.channel_name === 'Public'
@@ -4,6 +4,7 @@ import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
import { handleKeyboardActivate } from '../utils/a11y';
import type { Contact } from '../types';
interface ContactStatusInfoProps {
@@ -30,6 +31,9 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
<span
key="path"
className="cursor-pointer hover:text-primary hover:underline"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
if (window.confirm('Reset path to flood?')) {
@@ -49,6 +53,9 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
<span
key="path"
className="cursor-pointer hover:text-primary hover:underline"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
if (window.confirm('Reset path to flood?')) {
@@ -74,6 +81,9 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
<span key="coords">
<span
className="font-mono cursor-pointer hover:text-primary hover:underline"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
const url =
+2 -2
View File
@@ -526,7 +526,7 @@ export function CrackerPanel({
onClick={isRunning ? handleStop : handleStart}
disabled={!wordlistLoaded || gpuAvailable === false}
className={cn(
'w-48 px-4 py-1.5 rounded text-sm font-medium',
'w-48 px-4 py-1.5 rounded text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isRunning
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90',
@@ -548,7 +548,7 @@ export function CrackerPanel({
Pending: <span className="text-foreground font-medium">{pendingCount}</span>
</span>
<span className="text-muted-foreground">
Cracked: <span className="text-green-500 font-medium">{crackedCount}</span>
Cracked: <span className="text-green-400 font-medium">{crackedCount}</span>
</span>
<span className="text-muted-foreground">
Failed: <span className="text-destructive font-medium">{failedCount}</span>
+5 -1
View File
@@ -186,7 +186,11 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
<Popup>
<div className="text-sm">
<div className="font-medium flex items-center gap-1">
{isRepeater && <span title="Repeater">🛜</span>}
{isRepeater && (
<span title="Repeater" aria-hidden="true">
🛜
</span>
)}
{displayName}
</div>
<div className="text-xs text-gray-500 mt-1">
+5 -4
View File
@@ -160,6 +160,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
type="text"
autoComplete="off"
name="chat-message-input"
aria-label={placeholder || 'Type a message'}
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
@@ -185,7 +186,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
className={cn(
'tabular-nums',
limitState === 'error' || limitState === 'danger'
? 'text-red-500 font-medium'
? 'text-red-400 font-medium'
: limitState === 'warning'
? 'text-yellow-500'
: 'text-muted-foreground'
@@ -195,7 +196,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
{remaining < 0 && ` (${remaining})`}
</span>
{warningMessage && (
<span className={cn(limitState === 'error' ? 'text-red-500' : 'text-yellow-500')}>
<span className={cn(limitState === 'error' ? 'text-red-400' : 'text-yellow-500')}>
{warningMessage}
</span>
)}
@@ -208,7 +209,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
className={cn(
'tabular-nums',
limitState === 'error' || limitState === 'danger'
? 'text-red-500 font-medium'
? 'text-red-400 font-medium'
: limitState === 'warning'
? 'text-yellow-500'
: 'text-muted-foreground'
@@ -219,7 +220,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
</span>
)}
{warningMessage && (
<span className={cn(limitState === 'error' ? 'text-red-500' : 'text-yellow-500')}>
<span className={cn(limitState === 'error' ? 'text-red-400' : 'text-yellow-500')}>
{warningMessage}
</span>
)}
+28 -3
View File
@@ -13,6 +13,7 @@ import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
import { ContactAvatar } from './ContactAvatar';
import { PathModal } from './PathModal';
import { handleKeyboardActivate } from '../utils/a11y';
import { cn } from '@/lib/utils';
interface MessageListProps {
@@ -125,6 +126,9 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
return (
<span
className={className}
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
onClick();
@@ -377,7 +381,7 @@ export function MessageList({
if (loading) {
return (
<div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">
<div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground" role="status">
Loading messages...
</div>
);
@@ -404,9 +408,11 @@ export function MessageList({
className="h-full overflow-y-auto p-4 flex flex-col gap-0.5"
ref={listRef}
onScroll={handleScroll}
aria-live="polite"
aria-relevant="additions"
>
{loadingOlder && (
<div className="text-center py-2 text-muted-foreground text-sm">
<div className="text-center py-2 text-muted-foreground text-sm" role="status">
Loading older messages...
</div>
)}
@@ -469,6 +475,15 @@ export function MessageList({
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar && avatarKey && (
<span
role={
onOpenContactInfo && !avatarKey.startsWith('name:') ? 'button' : undefined
}
tabIndex={onOpenContactInfo && !avatarKey.startsWith('name:') ? 0 : undefined}
onKeyDown={
onOpenContactInfo && !avatarKey.startsWith('name:')
? handleKeyboardActivate
: undefined
}
onClick={
onOpenContactInfo && !avatarKey.startsWith('name:')
? () => onOpenContactInfo(avatarKey)
@@ -496,6 +511,9 @@ export function MessageList({
{canClickSender ? (
<span
className="cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => onSenderClick(displaySender)}
title={`Mention ${displaySender}`}
>
@@ -552,6 +570,9 @@ export function MessageList({
msg.paths && msg.paths.length > 0 ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
@@ -569,6 +590,9 @@ export function MessageList({
) : onResendChannelMessage && msg.type === 'CHAN' ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
@@ -600,8 +624,9 @@ export function MessageList({
{showScrollToBottom && (
<button
onClick={scrollToBottom}
className="absolute bottom-4 right-4 w-9 h-9 rounded-full bg-card hover:bg-accent border border-border flex items-center justify-center shadow-lg transition-all hover:scale-105"
className="absolute bottom-4 right-4 w-9 h-9 rounded-full bg-card hover:bg-accent border border-border flex items-center justify-center shadow-lg transition-all hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title="Scroll to bottom"
aria-label="Scroll to bottom"
>
<svg
xmlns="http://www.w3.org/2000/svg"
+16 -3
View File
@@ -174,7 +174,15 @@ export function NewMessageModal({
contacts.map((contact) => (
<div
key={contact.public_key}
className="cursor-pointer px-4 py-2 hover:bg-accent"
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(e.currentTarget as HTMLElement).click();
}
}}
onClick={() => {
onSelectConversation({
type: 'contact',
@@ -246,8 +254,9 @@ export function NewMessageModal({
setRoomKey(hex);
}}
title="Generate random key"
aria-label="Generate random key"
>
🎲
<span aria-hidden="true">🎲</span>
</Button>
</div>
</div>
@@ -309,7 +318,11 @@ export function NewMessageModal({
</div>
)}
{error && <div className="text-sm text-destructive">{error}</div>}
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
<DialogFooter>
<Button
@@ -1649,7 +1649,12 @@ export function PacketVisualizer3D({
}, []);
return (
<div ref={containerRef} className="w-full h-full bg-background relative overflow-hidden">
<div
ref={containerRef}
className="w-full h-full bg-background relative overflow-hidden"
role="img"
aria-label="3D mesh network visualizer showing radio nodes as colored spheres and packet transmissions as animated arcs between them"
>
{/* Legend */}
{showControls && (
<div className="absolute bottom-4 left-4 bg-background/80 backdrop-blur-sm rounded-lg p-3 text-xs border border-border z-10">
+8
View File
@@ -426,6 +426,14 @@ function CoordinateLink({ lat, lon, publicKey }: { lat: number; lon: number; pub
return (
<span
className="text-xs text-muted-foreground font-mono cursor-pointer hover:text-primary hover:underline ml-1"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(e.currentTarget as HTMLElement).click();
}
}}
onClick={handleClick}
title="View on map"
>
+11 -2
View File
@@ -209,7 +209,12 @@ export function RawPacketList({ packets }: RawPacketListProps) {
}
return (
<div className="h-full overflow-y-auto p-4 flex flex-col gap-2" ref={listRef}>
<div
className="h-full overflow-y-auto p-4 flex flex-col gap-2"
ref={listRef}
aria-live="polite"
aria-relevant="additions"
>
{sortedPackets.map(({ packet, decoded }) => (
<div
key={getRawPacketObservationKey(packet)}
@@ -225,7 +230,11 @@ export function RawPacketList({ packets }: RawPacketListProps) {
</span>
{/* Encryption status */}
{!packet.decrypted && <span title="Encrypted">🔒</span>}
{!packet.decrypted && (
<span title="Encrypted" aria-hidden="true">
🔒
</span>
)}
{/* Summary */}
<span
+15 -8
View File
@@ -3,6 +3,7 @@ import { Button } from './ui/button';
import { RepeaterLogin } from './RepeaterLogin';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Contact, Conversation, Favorite } from '../types';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
@@ -69,11 +70,14 @@ export function RepeaterDashboard({
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
<span className="flex-shrink-0 font-semibold text-base">{conversation.name}</span>
<span
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => {
navigator.clipboard.writeText(conversation.id);
toast.success('Contact key copied!');
@@ -91,22 +95,24 @@ export function RepeaterDashboard({
size="sm"
onClick={loadAll}
disabled={anyLoading}
className="text-xs border-green-600 text-green-600 hover:bg-green-600/10 hover:text-green-600"
className="text-xs border-green-400 text-green-400 hover:bg-green-400/10 hover:text-green-400"
>
{anyLoading ? 'Loading...' : 'Load All'}
</Button>
)}
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onTrace}
title="Direct Trace"
aria-label="Direct Trace"
>
&#x1F6CE;
<span aria-hidden="true">&#x1F6CE;</span>
</button>
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onToggleFavorite('contact', conversation.id)}
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
>
{isFav ? (
<span className="text-amber-400">&#9733;</span>
@@ -115,14 +121,15 @@ export function RepeaterDashboard({
)}
</button>
<button
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors"
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onDeleteContact(conversation.id)}
title="Delete"
aria-label="Delete"
>
&#128465;
<span aria-hidden="true">&#128465;</span>
</button>
</div>
</div>
</header>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4">
+6 -1
View File
@@ -47,11 +47,16 @@ export function RepeaterLogin({
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Repeater password..."
aria-label="Repeater password"
disabled={loading}
autoFocus
/>
{error && <p className="text-sm text-destructive text-center">{error}</p>}
{error && (
<p className="text-sm text-destructive text-center" role="alert">
{error}
</p>
)}
<div className="flex flex-col gap-2">
<Button type="submit" disabled={loading} className="w-full">
+27 -20
View File
@@ -141,14 +141,21 @@ export function SettingsModal(props: SettingsModalProps) {
: 'w-full h-full overflow-y-auto space-y-3';
const sectionButtonClasses =
'w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/40';
'w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset';
const renderSectionHeader = (section: SettingsSection): ReactNode => {
if (!showSectionButton) return null;
return (
<button type="button" className={sectionButtonClasses} onClick={() => toggleSection(section)}>
<span className="font-medium">{SETTINGS_SECTION_LABELS[section]}</span>
<span className="text-muted-foreground md:hidden">
<button
type="button"
className={sectionButtonClasses}
aria-expanded={expandedSections[section]}
onClick={() => toggleSection(section)}
>
<span className="font-medium" role="heading" aria-level={3}>
{SETTINGS_SECTION_LABELS[section]}
</span>
<span className="text-muted-foreground md:hidden" aria-hidden="true">
{expandedSections[section] ? '' : '+'}
</span>
</button>
@@ -164,7 +171,7 @@ export function SettingsModal(props: SettingsModalProps) {
) : (
<div className={settingsContainerClass}>
{shouldRenderSection('radio') && (
<div className={sectionWrapperClass}>
<section className={sectionWrapperClass}>
{renderSectionHeader('radio')}
{isSectionVisible('radio') && (
<SettingsRadioSection
@@ -176,11 +183,11 @@ export function SettingsModal(props: SettingsModalProps) {
className={sectionContentClass}
/>
)}
</div>
</section>
)}
{shouldRenderSection('identity') && (
<div className={sectionWrapperClass}>
<section className={sectionWrapperClass}>
{renderSectionHeader('identity')}
{isSectionVisible('identity') && appSettings && (
<SettingsIdentitySection
@@ -197,11 +204,11 @@ export function SettingsModal(props: SettingsModalProps) {
className={sectionContentClass}
/>
)}
</div>
</section>
)}
{shouldRenderSection('connectivity') && (
<div className={sectionWrapperClass}>
<section className={sectionWrapperClass}>
{renderSectionHeader('connectivity')}
{isSectionVisible('connectivity') && appSettings && (
<SettingsConnectivitySection
@@ -214,11 +221,11 @@ export function SettingsModal(props: SettingsModalProps) {
className={sectionContentClass}
/>
)}
</div>
</section>
)}
{shouldRenderSection('database') && (
<div className={sectionWrapperClass}>
<section className={sectionWrapperClass}>
{renderSectionHeader('database')}
{isSectionVisible('database') && appSettings && (
<SettingsDatabaseSection
@@ -230,11 +237,11 @@ export function SettingsModal(props: SettingsModalProps) {
className={sectionContentClass}
/>
)}
</div>
</section>
)}
{shouldRenderSection('bot') && (
<div className={sectionWrapperClass}>
<section className={sectionWrapperClass}>
{renderSectionHeader('bot')}
{isSectionVisible('bot') && appSettings && (
<SettingsBotSection
@@ -244,11 +251,11 @@ export function SettingsModal(props: SettingsModalProps) {
className={sectionContentClass}
/>
)}
</div>
</section>
)}
{shouldRenderSection('mqtt') && (
<div className={sectionWrapperClass}>
<section className={sectionWrapperClass}>
{renderSectionHeader('mqtt')}
{isSectionVisible('mqtt') && appSettings && (
<SettingsMqttSection
@@ -258,23 +265,23 @@ export function SettingsModal(props: SettingsModalProps) {
className={sectionContentClass}
/>
)}
</div>
</section>
)}
{shouldRenderSection('statistics') && (
<div className={sectionWrapperClass}>
<section className={sectionWrapperClass}>
{renderSectionHeader('statistics')}
{isSectionVisible('statistics') && (
<SettingsStatisticsSection className={sectionContentClass} />
)}
</div>
</section>
)}
{shouldRenderSection('about') && (
<div className={sectionWrapperClass}>
<section className={sectionWrapperClass}>
{renderSectionHeader('about')}
{isSectionVisible('about') && <SettingsAboutSection className={sectionContentClass} />}
</div>
</section>
)}
</div>
);
+65 -18
View File
@@ -8,6 +8,7 @@ import {
} from '../types';
import { getStateKey, type ConversationTimes, type SortOrder } from '../utils/conversationState';
import { getContactDisplayName } from '../utils/pubkey';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { isFavorite } from '../utils/favorites';
import { Input } from './ui/input';
@@ -378,10 +379,14 @@ export function Sidebar({
<div
key={row.key}
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors',
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive(row.type, row.id) && 'bg-accent border-l-primary',
row.unreadCount > 0 && '[&_.name]:font-semibold [&_.name]:text-foreground'
)}
role="button"
tabIndex={0}
aria-current={isActive(row.type, row.id) ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: row.type,
@@ -407,6 +412,7 @@ export function Sidebar({
? 'bg-destructive text-destructive-foreground'
: 'bg-primary/90 text-primary-foreground'
)}
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
>
{row.unreadCount}
</span>
@@ -444,30 +450,37 @@ export function Sidebar({
<div className="flex justify-between items-center px-3 py-2 pt-3.5">
<button
className={cn(
'flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors',
'flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
isSearching && 'cursor-default'
)}
aria-expanded={!effectiveCollapsed}
onClick={() => {
if (!isSearching) onToggle();
}}
title={effectiveCollapsed ? `Expand ${title}` : `Collapse ${title}`}
>
<span className="text-[9px]">{effectiveCollapsed ? '▸' : '▾'}</span>
<span className="text-[9px]" aria-hidden="true">
{effectiveCollapsed ? '▸' : '▾'}
</span>
<span>{title}</span>
</button>
{(showSortToggle || unreadCount > 0) && (
<div className="ml-auto flex items-center gap-1.5">
{showSortToggle && (
<button
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[10px] rounded hover:text-foreground transition-colors"
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[10px] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={handleSortToggle}
aria-label={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
>
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
</button>
)}
{unreadCount > 0 && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-secondary text-muted-foreground">
<span
className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-secondary text-muted-foreground"
aria-label={`${unreadCount} unread`}
>
{unreadCount}
</span>
)}
@@ -478,7 +491,10 @@ export function Sidebar({
};
return (
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
<nav
className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col"
aria-label="Conversations"
>
{/* Header */}
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
@@ -489,6 +505,7 @@ export function Sidebar({
size="sm"
onClick={onNewMessage}
title="New Message"
aria-label="New message"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground transition-colors"
>
+
@@ -500,15 +517,17 @@ export function Sidebar({
<Input
type="text"
placeholder="Search..."
aria-label="Search conversations"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-7 text-[13px] pr-8 bg-background/50"
/>
{searchQuery && (
<button
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors"
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
onClick={() => setSearchQuery('')}
title="Clear search"
aria-label="Clear search"
>
×
</button>
@@ -521,9 +540,13 @@ export function Sidebar({
{!query && (
<div
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive('raw', 'raw') && 'bg-accent border-l-primary'
)}
role="button"
tabIndex={0}
aria-current={isActive('raw', 'raw') ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: 'raw',
@@ -532,7 +555,9 @@ export function Sidebar({
})
}
>
<span className="text-muted-foreground text-xs">📡</span>
<span className="text-muted-foreground text-xs" aria-hidden="true">
📡
</span>
<span className="flex-1 truncate text-muted-foreground">Packet Feed</span>
</div>
)}
@@ -541,9 +566,13 @@ export function Sidebar({
{!query && (
<div
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive('map', 'map') && 'bg-accent border-l-primary'
)}
role="button"
tabIndex={0}
aria-current={isActive('map', 'map') ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: 'map',
@@ -552,7 +581,9 @@ export function Sidebar({
})
}
>
<span className="text-muted-foreground text-xs">🗺</span>
<span className="text-muted-foreground text-xs" aria-hidden="true">
🗺
</span>
<span className="flex-1 truncate text-muted-foreground">Node Map</span>
</div>
)}
@@ -561,9 +592,13 @@ export function Sidebar({
{!query && (
<div
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive('visualizer', 'visualizer') && 'bg-accent border-l-primary'
)}
role="button"
tabIndex={0}
aria-current={isActive('visualizer', 'visualizer') ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: 'visualizer',
@@ -572,7 +607,9 @@ export function Sidebar({
})
}
>
<span className="text-muted-foreground text-xs"></span>
<span className="text-muted-foreground text-xs" aria-hidden="true">
</span>
<span className="flex-1 truncate text-muted-foreground">Mesh Visualizer</span>
</div>
)}
@@ -581,12 +618,17 @@ export function Sidebar({
{!query && (
<div
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
showCracker && 'bg-accent border-l-primary'
)}
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={onToggleCracker}
>
<span className="text-muted-foreground text-xs">🔓</span>
<span className="text-muted-foreground text-xs" aria-hidden="true">
🔓
</span>
<span className="flex-1 truncate text-muted-foreground">
{showCracker ? 'Hide' : 'Show'} Room Finder
<span
@@ -604,10 +646,15 @@ export function Sidebar({
{/* Mark All Read */}
{!query && Object.values(unreadCounts).some((c) => c > 0) && (
<div
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]"
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={onMarkAllRead}
>
<span className="text-muted-foreground text-xs"></span>
<span className="text-muted-foreground text-xs" aria-hidden="true">
</span>
<span className="flex-1 truncate text-muted-foreground">Mark all as read</span>
</div>
)}
@@ -682,6 +729,6 @@ export function Sidebar({
</div>
)}
</div>
</div>
</nav>
);
}
+14 -5
View File
@@ -3,6 +3,7 @@ import { Menu } from 'lucide-react';
import type { HealthStatus, RadioConfig } from '../types';
import { api } from '../api';
import { toast } from './ui/sonner';
import { handleKeyboardActivate } from '../utils/a11y';
import { cn } from '@/lib/utils';
interface StatusBarProps {
@@ -40,7 +41,7 @@ export function StatusBar({
};
return (
<div className="flex items-center gap-3 px-4 py-2.5 bg-card border-b border-border text-xs">
<header className="flex items-center gap-3 px-4 py-2.5 bg-card border-b border-border text-xs">
{/* Mobile menu button - only visible on small screens */}
{onMenuClick && (
<button
@@ -66,7 +67,11 @@ export function StatusBar({
RemoteTerm
</h1>
<div className="flex items-center gap-1.5">
<div
className="flex items-center gap-1.5"
role="status"
aria-label={connected ? 'Connected' : 'Disconnected'}
>
<div
className={cn(
'w-2 h-2 rounded-full transition-colors',
@@ -74,6 +79,7 @@ export function StatusBar({
? 'bg-primary shadow-[0_0_6px_hsl(var(--primary)/0.5)]'
: 'bg-muted-foreground'
)}
aria-hidden="true"
/>
<span className="hidden lg:inline text-muted-foreground">
{connected ? 'Connected' : 'Disconnected'}
@@ -85,6 +91,9 @@ export function StatusBar({
<span className="text-foreground font-medium">{config.name || 'Unnamed'}</span>
<span
className="font-mono text-[11px] text-muted-foreground cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => {
navigator.clipboard.writeText(config.public_key);
toast.success('Public key copied!');
@@ -100,17 +109,17 @@ export function StatusBar({
<button
onClick={handleReconnect}
disabled={reconnecting}
className="px-3 py-1 bg-amber-500/10 border border-amber-500/20 text-amber-400 rounded-md text-xs cursor-pointer hover:bg-amber-500/15 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1 bg-amber-500/10 border border-amber-500/20 text-amber-400 rounded-md text-xs cursor-pointer hover:bg-amber-500/15 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{reconnecting ? 'Reconnecting...' : 'Reconnect'}
</button>
)}
<button
onClick={onSettingsClick}
className="px-3 py-1.5 bg-secondary border border-border text-muted-foreground rounded-md text-xs cursor-pointer hover:bg-accent hover:text-foreground transition-colors"
className="px-3 py-1.5 bg-secondary border border-border text-muted-foreground rounded-md text-xs cursor-pointer hover:bg-accent hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{settingsMode ? 'Back to Chat' : 'Settings'}
</button>
</div>
</header>
);
}
+2 -1
View File
@@ -42,9 +42,10 @@ export function VisualizerView({ packets, contacts, config }: VisualizerViewProp
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
<span>Mesh Visualizer</span>
<button
className="hidden md:inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:text-foreground transition-colors"
className="hidden md:inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={toggleFullScreen}
title={paneFullScreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={paneFullScreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{paneFullScreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
@@ -16,7 +16,7 @@ export function AclPane({
const permColor: Record<number, string> = {
0: 'bg-muted text-muted-foreground',
1: 'bg-blue-500/10 text-blue-500',
2: 'bg-green-500/10 text-green-500',
2: 'bg-green-400/10 text-green-400',
3: 'bg-amber-500/10 text-amber-500',
};
@@ -40,6 +40,8 @@ export function ConsolePane({
<div
ref={outputRef}
className="h-48 overflow-y-auto p-3 font-mono text-xs bg-black/50 text-green-400 space-y-1"
aria-live="polite"
aria-relevant="additions"
>
{history.length === 0 && (
<p className="text-muted-foreground italic">Type a CLI command below...</p>
@@ -65,6 +67,7 @@ export function ConsolePane({
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="CLI command..."
aria-label="Console command"
disabled={loading}
className="flex-1 font-mono text-sm"
/>
@@ -101,7 +101,7 @@ export function NeighborsPane({
const dist = n.distance;
const snrStr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1);
const snrColor =
n.snr >= 6 ? 'text-green-500' : n.snr >= 0 ? 'text-yellow-500' : 'text-red-500';
n.snr >= 6 ? 'text-green-400' : n.snr >= 0 ? 'text-yellow-500' : 'text-red-400';
return (
<tr key={i} className="border-t border-border/50">
<td className="py-1">{n.name || n.pubkey_prefix}</td>
@@ -66,7 +66,7 @@ export function RadioSettingsPane({
<span
className={cn(
'ml-2 text-xs',
clockDrift.isLarge ? 'text-red-500' : 'text-muted-foreground'
clockDrift.isLarge ? 'text-red-400' : 'text-muted-foreground'
)}
>
(drift: {clockDrift.text})
@@ -109,12 +109,13 @@ export function RepeaterPane({
onClick={onRefresh}
disabled={disabled || state.loading}
className={cn(
'p-1 rounded transition-colors disabled:opacity-50',
'p-1 rounded transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
disabled || state.loading
? 'text-muted-foreground'
: 'text-green-500 hover:bg-accent hover:text-green-400'
)}
title="Refresh"
aria-label={`Refresh ${title}`}
>
<RefreshIcon
className={cn(
@@ -3,6 +3,7 @@ import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { handleKeyboardActivate } from '../../utils/a11y';
import type { AppSettings, AppSettingsUpdate, BotConfig } from '../../types';
const BotCodeEditor = lazy(() =>
@@ -141,7 +142,7 @@ export function SettingsBotSection({
return (
<div className={className}>
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-md">
<p className="text-sm text-red-500">
<p className="text-sm text-red-400">
<strong>Experimental:</strong> This is an alpha feature and introduces automated message
sending to your radio; unexpected behavior may occur. Use with caution, and please report
any bugs!
@@ -182,12 +183,16 @@ export function SettingsBotSection({
<div key={bot.id} className="border border-input rounded-md overflow-hidden">
<div
className="flex items-center gap-2 px-3 py-2 bg-muted/50 cursor-pointer hover:bg-muted/80"
role="button"
tabIndex={0}
aria-expanded={expandedBotId === bot.id}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
if ((e.target as HTMLElement).closest('input, button')) return;
setExpandedBotId(expandedBotId === bot.id ? null : bot.id);
}}
>
<span className="text-muted-foreground">
<span className="text-muted-foreground" aria-hidden="true">
{expandedBotId === bot.id ? '▼' : '▶'}
</span>
@@ -211,6 +216,9 @@ export function SettingsBotSection({
) : (
<span
className="text-sm font-medium flex-1 hover:text-primary cursor-text"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
handleStartEditingName(bot);
@@ -244,8 +252,9 @@ export function SettingsBotSection({
handleDeleteBot(bot.id);
}}
title="Delete bot"
aria-label="Delete bot"
>
🗑
<span aria-hidden="true">🗑</span>
</Button>
</div>
@@ -303,7 +312,11 @@ export function SettingsBotSection({
</p>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Bot Settings'}
@@ -128,7 +128,11 @@ export function SettingsConnectivitySection({
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
</Button>
{error && <div className="text-sm text-destructive">{error}</div>}
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
</div>
);
}
@@ -251,6 +251,7 @@ export function SettingsDatabaseSection({
onLocalLabelChange?.({ text, color: localLabelColor });
}}
placeholder="e.g. Home Base, Field Radio 2"
aria-label="Local label text"
className="flex-1"
/>
<input
@@ -262,6 +263,7 @@ export function SettingsDatabaseSection({
setLocalLabel(localLabelText, color);
onLocalLabelChange?.({ text: localLabelText, color });
}}
aria-label="Local label color"
className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5"
/>
</div>
@@ -271,7 +273,11 @@ export function SettingsDatabaseSection({
</p>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}
@@ -184,7 +184,11 @@ export function SettingsIdentitySection({
)}
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
</div>
);
}
@@ -125,10 +125,13 @@ export function SettingsMqttSection({
<div className="border border-input rounded-md overflow-hidden">
<button
type="button"
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/40"
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
aria-expanded={privateExpanded}
onClick={() => setPrivateExpanded(!privateExpanded)}
>
<span className="text-muted-foreground">{privateExpanded ? '▼' : '▶'}</span>
<span className="text-muted-foreground" aria-hidden="true">
{privateExpanded ? '▼' : '▶'}
</span>
<h4 className="text-sm font-medium">Private MQTT Broker</h4>
{health?.mqtt_status === 'connected' ? (
<>
@@ -308,10 +311,13 @@ export function SettingsMqttSection({
<div className="border border-input rounded-md overflow-hidden">
<button
type="button"
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/40"
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
aria-expanded={communityExpanded}
onClick={() => setCommunityExpanded(!communityExpanded)}
>
<span className="text-muted-foreground">{communityExpanded ? '▼' : '▶'}</span>
<span className="text-muted-foreground" aria-hidden="true">
{communityExpanded ? '▼' : '▶'}
</span>
<h4 className="text-sm font-medium">Community Analytics</h4>
{health?.community_mqtt_status === 'connected' ? (
<>
@@ -427,7 +433,11 @@ export function SettingsMqttSection({
{busy ? 'Saving...' : 'Save MQTT Settings'}
</Button>
{error && <div className="text-sm text-destructive">{error}</div>}
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
</div>
);
}
@@ -276,7 +276,11 @@ export function SettingsRadioSection({
</div>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
<Button onClick={handleSave} disabled={busy || rebooting} className="w-full">
{busy || rebooting ? 'Saving & Rebooting...' : 'Save Radio Config & Reboot'}
@@ -88,8 +88,8 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<span className="font-medium">{stats.total_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-green-500">Decrypted</span>
<span className="font-medium text-green-500">{stats.decrypted_packets}</span>
<span className="text-sm text-green-400">Decrypted</span>
<span className="font-medium text-green-400">{stats.decrypted_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-yellow-500">Undecrypted</span>
+1 -1
View File
@@ -16,7 +16,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
// Muted error style - dark red-tinted background with readable text
error:
'group-[.toaster]:bg-[#2a1a1a] group-[.toaster]:text-[#e8a0a0] group-[.toaster]:border-[#4a2a2a] [&_[data-description]]:text-[#d4a0a0]',
'group-[.toaster]:bg-[#2a1a1a] group-[.toaster]:text-[#e8a0a0] group-[.toaster]:border-[#4a2a2a] [&_[data-description]]:text-[#e8b0b0]',
},
}}
{...props}
+3 -3
View File
@@ -15,13 +15,13 @@
--secondary: 224 12% 17%;
--secondary-foreground: 213 12% 85%;
--muted: 224 12% 15%;
--muted-foreground: 218 10% 60%;
--muted-foreground: 218 10% 65%;
--accent: 224 11% 19%;
--accent-foreground: 213 15% 90%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 224 11% 17%;
--input: 224 11% 17%;
--border: 224 11% 23%;
--input: 224 11% 23%;
--ring: 152 60% 38%;
--radius: 0.5rem;
+9
View File
@@ -0,0 +1,9 @@
import type { KeyboardEvent } from 'react';
/** Activate a clickable non-button element on Enter or Space, mirroring native button behavior. */
export function handleKeyboardActivate(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(e.currentTarget as HTMLElement).click();
}
}