A11y bug bash

This commit is contained in:
Jack Kingsman
2026-03-05 10:24:22 -08:00
parent c7bd4dd3fc
commit 01a5dc8d93
22 changed files with 114 additions and 52 deletions

View File

@@ -54,7 +54,13 @@ const CrackerPanel = lazy(() =>
const SearchView = lazy(() =>
import('./components/SearchView').then((m) => ({ default: m.SearchView }))
);
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from './components/ui/sheet';
import { Toaster, toast } from './components/ui/sonner';
import { getStateKey } from './utils/conversationState';
import { appendRawPacketUnique } from './utils/rawPacketIdentity';
@@ -637,6 +643,12 @@ export function App() {
return (
<div className="flex flex-col h-full">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-2 focus:bg-primary focus:text-primary-foreground"
>
Skip to content
</a>
{localLabel.text && (
<div
style={{
@@ -665,12 +677,13 @@ export function App() {
<SheetContent side="left" className="w-[280px] p-0 flex flex-col" hideCloseButton>
<SheetHeader className="sr-only">
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>Sidebar navigation</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-hidden">{activeSidebarContent}</div>
</SheetContent>
</Sheet>
<main className="flex-1 flex flex-col bg-background min-w-0">
<main id="main-content" className="flex-1 flex flex-col bg-background min-w-0">
<div
className={cn(
'flex-1 flex flex-col min-h-0',
@@ -680,9 +693,9 @@ export function App() {
{activeConversation ? (
activeConversation.type === 'map' ? (
<>
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
Node Map
</div>
</h2>
<div className="flex-1 overflow-hidden">
<Suspense
fallback={
@@ -707,9 +720,9 @@ export function App() {
</Suspense>
) : activeConversation.type === 'raw' ? (
<>
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
Raw Packet Feed
</div>
</h2>
<div className="flex-1 overflow-hidden">
<RawPacketList packets={rawPackets} />
</div>
@@ -820,12 +833,12 @@ export function App() {
{showSettings && (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
<span>Radio & Settings</span>
<span className="text-sm text-muted-foreground hidden md:inline">
{SETTINGS_SECTION_LABELS[settingsSection]}
</span>
</div>
</h2>
<div className="flex-1 min-h-0 overflow-hidden">
<Suspense
fallback={
@@ -865,6 +878,13 @@ export function App() {
{/* Global Cracker Panel - deferred until first opened, then kept mounted for state */}
<div
ref={(el) => {
// Focus the panel when it becomes visible
if (showCracker && el) {
const focusable = el.querySelector<HTMLElement>('input, button:not([disabled])');
if (focusable) setTimeout(() => focusable.focus(), 210);
}
}}
className={cn(
'border-t border-border bg-background transition-all duration-200 overflow-hidden',
showCracker ? 'h-[275px]' : 'h-0'

View File

@@ -25,6 +25,7 @@ export function BotCodeEditor({ value, onChange, id, height = '256px' }: BotCode
}}
className="text-sm"
id={id}
aria-label="Bot code editor"
/>
</div>
);

View File

@@ -3,7 +3,7 @@ import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './ui/sheet';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import type { Channel, ChannelDetail, Favorite } from '../types';
@@ -65,6 +65,7 @@ export function ChannelInfoPane({
<SheetContent side="right" className="w-full sm:max-w-[400px] p-0 flex flex-col">
<SheetHeader className="sr-only">
<SheetTitle>Channel Info</SheetTitle>
<SheetDescription>Channel details and statistics</SheetDescription>
</SheetHeader>
{loading && !detail ? (

View File

@@ -117,6 +117,7 @@ export function ChatHeader({
);
}}
title="Click to copy"
aria-label={conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'}
>
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
</span>

View File

@@ -6,7 +6,7 @@ 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 { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import type { Contact, ContactDetail, Favorite, RadioConfig } from '../types';
@@ -98,6 +98,7 @@ export function ContactInfoPane({
<SheetContent side="right" className="w-full sm:max-w-[400px] p-0 flex flex-col">
<SheetHeader className="sr-only">
<SheetTitle>Contact Info</SheetTitle>
<SheetDescription>Contact details and actions</SheetDescription>
</SheetHeader>
{isNameOnly && nameOnlyValue ? (

View File

@@ -586,7 +586,14 @@ export function CrackerPanel({
: `${Math.round(progress.etaSeconds / 60)}m`}
</span>
</div>
<div className="h-2 bg-muted rounded overflow-hidden">
<div
className="h-2 bg-muted rounded overflow-hidden"
role="progressbar"
aria-valuenow={Math.round(progress.percent)}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Cracking progress"
>
<div
className="h-full bg-primary transition-all duration-200"
style={{ width: `${progress.percent}%` }}
@@ -597,12 +604,14 @@ export function CrackerPanel({
{/* GPU status */}
{gpuAvailable === false && (
<div className="text-sm text-destructive">
<div className="text-sm text-destructive" role="alert">
WebGPU not available. Cracking requires Chrome 113+ or Edge 113+.
</div>
)}
{!wordlistLoaded && gpuAvailable !== false && (
<div className="text-sm text-muted-foreground">Loading wordlist...</div>
<div className="text-sm text-muted-foreground" role="status">
Loading wordlist...
</div>
)}
{/* Cracked rooms list */}

View File

@@ -140,22 +140,27 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#22c55e]" /> &lt;1h
<span className="w-3 h-3 rounded-full bg-[#22c55e]" aria-hidden="true" /> &lt;1h
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#4ade80]" /> &lt;1d
<span className="w-3 h-3 rounded-full bg-[#4ade80]" aria-hidden="true" /> &lt;1d
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#a3e635]" /> &lt;3d
<span className="w-3 h-3 rounded-full bg-[#a3e635]" aria-hidden="true" /> &lt;3d
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#9ca3af]" /> older
<span className="w-3 h-3 rounded-full bg-[#9ca3af]" aria-hidden="true" /> older
</span>
</div>
</div>
{/* Map - z-index constrained to stay below modals/sheets */}
<div className="flex-1 relative" style={{ zIndex: 0 }}>
<div
className="flex-1 relative"
style={{ zIndex: 0 }}
role="img"
aria-label="Map showing mesh node locations"
>
<MapContainer
center={[20, 0]}
zoom={2}

View File

@@ -140,6 +140,7 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
onClick();
}}
title="View message path"
aria-label={`${hopInfo.display}, view path`}
>
{label}
</span>
@@ -462,8 +463,6 @@ 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" role="status">
@@ -629,6 +628,7 @@ export function MessageList({
});
}}
title="View echo paths"
aria-label={`Acknowledged, ${msg.acked} echo${msg.acked !== 1 ? 's' : ''} — view paths`}
>{`${msg.acked > 1 ? msg.acked : ''}`}</span>
) : (
<span className="text-muted-foreground">{`${msg.acked > 1 ? msg.acked : ''}`}</span>
@@ -649,6 +649,7 @@ export function MessageList({
});
}}
title="Message status"
aria-label="No echoes yet — view message status"
>
{' '}
?

View File

@@ -29,7 +29,11 @@ export function NeighborsMiniMap({ neighbors, radioLat, radioLon, radioName }: P
const center: [number, number] = hasRadio ? [radioLat!, radioLon!] : [valid[0].lat, valid[0].lon];
return (
<div className="min-h-48 flex-1 rounded border border-border overflow-hidden">
<div
className="min-h-48 flex-1 rounded border border-border overflow-hidden"
role="img"
aria-label="Map showing repeater neighbor locations"
>
<MapContainer
center={center}
zoom={10}

View File

@@ -1872,8 +1872,14 @@ export function PacketVisualizer3D({
</label>
{pruneStaleNodes && (
<div className="flex items-center gap-2 pl-6">
<span className="text-muted-foreground whitespace-nowrap">Window:</span>
<label
htmlFor="prune-window"
className="text-muted-foreground whitespace-nowrap"
>
Window:
</label>
<input
id="prune-window"
type="number"
min={1}
max={60}
@@ -1884,7 +1890,9 @@ export function PacketVisualizer3D({
}}
className="w-14 rounded border border-border bg-background px-2 py-0.5 text-sm"
/>
<span className="text-muted-foreground">min</span>
<span className="text-muted-foreground" aria-hidden="true">
min
</span>
</div>
)}
<label className="flex items-center gap-2 cursor-pointer">
@@ -1907,12 +1915,14 @@ export function PacketVisualizer3D({
</label>
<div className="flex flex-col gap-1 mt-1">
<label
htmlFor="viz-repulsion"
className="text-muted-foreground"
title="How strongly nodes repel each other. Higher values spread nodes out more."
>
Repulsion: {Math.abs(chargeStrength)}
</label>
<input
id="viz-repulsion"
type="range"
min="50"
max="2500"
@@ -1923,12 +1933,14 @@ export function PacketVisualizer3D({
</div>
<div className="flex flex-col gap-1 mt-1">
<label
htmlFor="viz-packet-speed"
className="text-muted-foreground"
title="How fast particles travel along links. Higher values make packets move faster."
>
Packet speed: {particleSpeedMultiplier}x
</label>
<input
id="viz-packet-speed"
type="range"
min="1"
max="5"

View File

@@ -151,6 +151,7 @@ export function PathModal({
)}
<button
onClick={toggleMap}
aria-expanded={mapExpanded}
className="text-xs text-primary hover:underline cursor-pointer shrink-0 ml-2"
>
{mapExpanded ? 'Hide map' : 'Map route'}

View File

@@ -113,7 +113,12 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
return (
<div>
<div className="rounded border border-border overflow-hidden" style={{ height: 220 }}>
<div
className="rounded border border-border overflow-hidden"
role="img"
aria-label="Map showing message route between nodes"
style={{ height: 220 }}
>
<MapContainer
center={center}
zoom={10}

View File

@@ -209,12 +209,7 @@ export function RawPacketList({ packets }: RawPacketListProps) {
}
return (
<div
className="h-full overflow-y-auto p-4 flex flex-col gap-2"
ref={listRef}
aria-live="polite"
aria-relevant="additions"
>
<div className="h-full overflow-y-auto p-4 flex flex-col gap-2" ref={listRef}>
{sortedPackets.map(({ packet, decoded }) => (
<div
key={getRawPacketObservationKey(packet)}
@@ -231,9 +226,10 @@ export function RawPacketList({ packets }: RawPacketListProps) {
{/* Encryption status */}
{!packet.decrypted && (
<span title="Encrypted" aria-hidden="true">
🔒
</span>
<>
<span aria-hidden="true">🔒</span>
<span className="sr-only">Encrypted</span>
</>
)}
{/* Summary */}

View File

@@ -172,9 +172,9 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
Message Search
</div>
</h2>
{/* Search input */}
<div className="px-4 py-3 border-b border-border">
@@ -254,7 +254,9 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
})}
{loading && (
<div className="p-4 text-center text-muted-foreground text-sm">Searching...</div>
<div className="p-4 text-center text-muted-foreground text-sm" role="status">
Searching...
</div>
)}
{hasMore && !loading && (

View File

@@ -99,6 +99,7 @@ export function StatusBar({
toast.success('Public key copied!');
}}
title="Click to copy public key"
aria-label="Copy public key"
>
{config.public_key.toLowerCase()}
</span>

View File

@@ -40,8 +40,6 @@ export function ConsolePane({
<div
ref={outputRef}
className="h-48 overflow-y-auto p-3 font-mono text-xs bg-console-bg/50 text-console space-y-1"
aria-live="polite"
aria-relevant="additions"
>
{history.length === 0 && (
<p className="text-muted-foreground italic">Type a CLI command below...</p>

View File

@@ -91,6 +91,7 @@ export function RadioSettingsPane({
: 'text-success hover:bg-accent hover:text-success'
)}
title="Refresh Advert Intervals"
aria-label="Refresh Advert Intervals"
>
<RefreshIcon
className={cn(

View File

@@ -3,7 +3,6 @@ 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, HealthStatus } from '../../types';
const BotCodeEditor = lazy(() =>
@@ -191,14 +190,12 @@ export function SettingsBotSection({
<div className="space-y-2">
{bots.map((bot) => (
<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}
<button
type="button"
className="flex items-center gap-2 px-3 py-2 bg-muted/50 cursor-pointer hover:bg-muted/80 w-full text-left"
aria-expanded={expandedBotId === bot.id}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
if ((e.target as HTMLElement).closest('input, button')) return;
if ((e.target as HTMLElement).closest('input, [data-bot-control]')) return;
setExpandedBotId(expandedBotId === bot.id ? null : bot.id);
}}
>
@@ -220,15 +217,14 @@ export function SettingsBotSection({
}
}}
autoFocus
aria-label="Bot name"
className="px-2 py-0.5 text-sm bg-background border border-input rounded flex-1 max-w-[200px]"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="text-sm font-medium flex-1 hover:text-primary cursor-text"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
className="text-sm font-medium flex-1 hover:text-primary cursor-text text-left"
data-bot-control
onClick={(e) => {
e.stopPropagation();
handleStartEditingName(bot);
@@ -241,6 +237,7 @@ export function SettingsBotSection({
<label
className="flex items-center gap-1.5 cursor-pointer"
data-bot-control
onClick={(e) => e.stopPropagation()}
>
<input
@@ -248,6 +245,7 @@ export function SettingsBotSection({
checked={bot.enabled}
onChange={() => handleToggleBotEnabled(bot.id)}
className="w-4 h-4 rounded border-input accent-primary"
aria-label={`Enable ${bot.name}`}
/>
<span className="text-xs text-muted-foreground">Enabled</span>
</label>
@@ -256,17 +254,18 @@ export function SettingsBotSection({
type="button"
variant="ghost"
size="sm"
data-bot-control
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteBot(bot.id);
}}
title="Delete bot"
aria-label="Delete bot"
aria-label={`Delete ${bot.name}`}
>
<span aria-hidden="true">🗑</span>
</Button>
</div>
</button>
{expandedBotId === bot.id && (
<div className="p-3 space-y-3 border-t border-input">

View File

@@ -26,11 +26,12 @@ export function ThemeSelector() {
return (
<fieldset className="flex flex-wrap gap-2">
<legend className="sr-only">Color theme</legend>
{THEMES.map((theme) => (
<label
key={theme.id}
className={
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer border transition-colors ' +
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer border transition-colors focus-within:ring-2 focus-within:ring-ring ' +
(current === theme.id
? 'border-primary bg-primary/5'
: 'border-transparent hover:bg-accent/50')

View File

@@ -150,6 +150,7 @@ vi.mock('../components/ui/sheet', () => ({
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/ui/sonner', () => ({

View File

@@ -177,6 +177,7 @@ vi.mock('../components/ui/sheet', () => ({
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/ui/sonner', () => ({

View File

@@ -119,6 +119,7 @@ vi.mock('../components/ui/sheet', () => ({
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/ui/sonner', () => ({