From fb279ccf1ad255a40bf9bf53e8c22f7627d8b969 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 2 Mar 2026 20:34:06 -0800 Subject: [PATCH] Accessibility overhaul --- LICENSES.md | 4 +- frontend/src/App.tsx | 12 ++- frontend/src/components/ChatHeader.tsx | 39 +++++++-- frontend/src/components/ContactAvatar.tsx | 1 + frontend/src/components/ContactInfoPane.tsx | 10 +++ frontend/src/components/ContactStatusInfo.tsx | 10 +++ frontend/src/components/CrackerPanel.tsx | 4 +- frontend/src/components/MapView.tsx | 6 +- frontend/src/components/MessageInput.tsx | 9 +- frontend/src/components/MessageList.tsx | 31 ++++++- frontend/src/components/NewMessageModal.tsx | 19 ++++- .../src/components/PacketVisualizer3D.tsx | 7 +- frontend/src/components/PathModal.tsx | 8 ++ frontend/src/components/RawPacketList.tsx | 13 ++- frontend/src/components/RepeaterDashboard.tsx | 23 +++-- frontend/src/components/RepeaterLogin.tsx | 7 +- frontend/src/components/SettingsModal.tsx | 47 ++++++----- frontend/src/components/Sidebar.tsx | 83 +++++++++++++++---- frontend/src/components/StatusBar.tsx | 19 +++-- frontend/src/components/VisualizerView.tsx | 3 +- .../components/repeater/RepeaterAclPane.tsx | 2 +- .../repeater/RepeaterConsolePane.tsx | 3 + .../repeater/RepeaterNeighborsPane.tsx | 2 +- .../repeater/RepeaterRadioSettingsPane.tsx | 2 +- .../repeater/repeaterPaneShared.tsx | 3 +- .../settings/SettingsBotSection.tsx | 21 ++++- .../settings/SettingsConnectivitySection.tsx | 6 +- .../settings/SettingsDatabaseSection.tsx | 8 +- .../settings/SettingsIdentitySection.tsx | 6 +- .../settings/SettingsMqttSection.tsx | 20 +++-- .../settings/SettingsRadioSection.tsx | 6 +- .../settings/SettingsStatisticsSection.tsx | 4 +- frontend/src/components/ui/sonner.tsx | 2 +- frontend/src/index.css | 6 +- frontend/src/utils/a11y.ts | 9 ++ 35 files changed, 348 insertions(+), 107 deletions(-) create mode 100644 frontend/src/utils/a11y.ts diff --git a/LICENSES.md b/LICENSES.md index 659251d..12419d5 100644 --- a/LICENSES.md +++ b/LICENSES.md @@ -722,7 +722,7 @@ SOFTWARE. -### @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. -### tailwind-merge (3.4.0) β€” MIT +### tailwind-merge (3.5.0) β€” MIT
Full license text diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0258c1d..f470a3e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -465,7 +465,10 @@ export function App() { ); const settingsSidebarContent = ( -
+
+ ); const activeSidebarContent = showSettings ? settingsSidebarContent : sidebarContent; diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 93d7ef1..9f6c129 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -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 ( -
+
{conversation.type === 'contact' && onOpenContactInfo && ( onOpenContactInfo(conversation.id)} title="View contact info" > @@ -45,8 +49,15 @@ export function ChatHeader({ /> )} - onOpenContactInfo(conversation.id) @@ -59,9 +70,12 @@ export function ChatHeader({ ? '#' : ''} {conversation.name} - + { e.stopPropagation(); navigator.clipboard.writeText(conversation.id); @@ -90,17 +104,18 @@ export function ChatHeader({ {/* Direct trace button (contacts only) */} {conversation.type === 'contact' && ( )} {/* Favorite button */} {(conversation.type === 'channel' || conversation.type === 'contact') && ( )}
- + ); } diff --git a/frontend/src/components/ContactAvatar.tsx b/frontend/src/components/ContactAvatar.tsx index 6e70c8c..e5cff78 100644 --- a/frontend/src/components/ContactAvatar.tsx +++ b/frontend/src/components/ContactAvatar.tsx @@ -27,6 +27,7 @@ export function ContactAvatar({ height: size, fontSize: size * 0.45, }} + aria-hidden="true" > {avatar.text} diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index 9b929f4..8528e7b 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -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({ { navigator.clipboard.writeText(contact.public_key); toast.success('Public key copied!'); @@ -167,6 +171,9 @@ export function ContactInfoPane({ Location { 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' diff --git a/frontend/src/components/ContactStatusInfo.tsx b/frontend/src/components/ContactStatusInfo.tsx index 5da6fce..c99ccb3 100644 --- a/frontend/src/components/ContactStatusInfo.tsx +++ b/frontend/src/components/ContactStatusInfo.tsx @@ -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 { e.stopPropagation(); if (window.confirm('Reset path to flood?')) { @@ -49,6 +53,9 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo { e.stopPropagation(); if (window.confirm('Reset path to flood?')) { @@ -74,6 +81,9 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo { e.stopPropagation(); const url = diff --git a/frontend/src/components/CrackerPanel.tsx b/frontend/src/components/CrackerPanel.tsx index 421d446..adf434c 100644 --- a/frontend/src/components/CrackerPanel.tsx +++ b/frontend/src/components/CrackerPanel.tsx @@ -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: {pendingCount} - Cracked: {crackedCount} + Cracked: {crackedCount} Failed: {failedCount} diff --git a/frontend/src/components/MapView.tsx b/frontend/src/components/MapView.tsx index bddd5cc..649cc98 100644 --- a/frontend/src/components/MapView.tsx +++ b/frontend/src/components/MapView.tsx @@ -186,7 +186,11 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
- {isRepeater && πŸ›œ} + {isRepeater && ( + + )} {displayName}
diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index 9a28017..07f2360 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -160,6 +160,7 @@ export const MessageInput = forwardRef(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(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(fu {remaining < 0 && ` (${remaining})`} {warningMessage && ( - + β€” {warningMessage} )} @@ -208,7 +209,7 @@ export const MessageInput = forwardRef(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(fu )} {warningMessage && ( - + β€” {warningMessage} )} diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 9c1b9e8..898dae4 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -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 ( { e.stopPropagation(); onClick(); @@ -377,7 +381,7 @@ export function MessageList({ if (loading) { return ( -
+
Loading messages...
); @@ -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 && ( -
+
Loading older messages...
)} @@ -469,6 +475,15 @@ export function MessageList({
{showAvatar && avatarKey && ( onOpenContactInfo(avatarKey) @@ -496,6 +511,9 @@ export function MessageList({ {canClickSender ? ( onSenderClick(displaySender)} title={`Mention ${displaySender}`} > @@ -552,6 +570,9 @@ export function MessageList({ msg.paths && msg.paths.length > 0 ? ( { e.stopPropagation(); setSelectedPath({ @@ -569,6 +590,9 @@ export function MessageList({ ) : onResendChannelMessage && msg.type === 'CHAN' ? ( { e.stopPropagation(); setSelectedPath({ @@ -600,8 +624,9 @@ export function MessageList({ {showScrollToBottom && (
@@ -309,7 +318,11 @@ export function NewMessageModal({
)} - {error &&
{error}
} + {error && ( +
+ {error} +
+ )} )}
-
+ {/* Body */}
diff --git a/frontend/src/components/RepeaterLogin.tsx b/frontend/src/components/RepeaterLogin.tsx index d1ad725..b6f7eef 100644 --- a/frontend/src/components/RepeaterLogin.tsx +++ b/frontend/src/components/RepeaterLogin.tsx @@ -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 &&

{error}

} + {error && ( +

+ {error} +

+ )}
@@ -164,7 +171,7 @@ export function SettingsModal(props: SettingsModalProps) { ) : (
{shouldRenderSection('radio') && ( -
+
{renderSectionHeader('radio')} {isSectionVisible('radio') && ( )} -
+ )} {shouldRenderSection('identity') && ( -
+
{renderSectionHeader('identity')} {isSectionVisible('identity') && appSettings && ( )} -
+ )} {shouldRenderSection('connectivity') && ( -
+
{renderSectionHeader('connectivity')} {isSectionVisible('connectivity') && appSettings && ( )} -
+ )} {shouldRenderSection('database') && ( -
+
{renderSectionHeader('database')} {isSectionVisible('database') && appSettings && ( )} -
+ )} {shouldRenderSection('bot') && ( -
+
{renderSectionHeader('bot')} {isSectionVisible('bot') && appSettings && ( )} -
+ )} {shouldRenderSection('mqtt') && ( -
+
{renderSectionHeader('mqtt')} {isSectionVisible('mqtt') && appSettings && ( )} -
+ )} {shouldRenderSection('statistics') && ( -
+
{renderSectionHeader('statistics')} {isSectionVisible('statistics') && ( )} -
+ )} {shouldRenderSection('about') && ( -
+
{renderSectionHeader('about')} {isSectionVisible('about') && } -
+ )}
); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 8debc32..9a336fa 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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({
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} @@ -444,30 +450,37 @@ export function Sidebar({
{(showSortToggle || unreadCount > 0) && (
{showSortToggle && ( )} {unreadCount > 0 && ( - + {unreadCount} )} @@ -478,7 +491,10 @@ export function Sidebar({ }; return ( -
+
+ ); } diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index 29e01d2..c56df47 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -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 ( -
+
{/* Mobile menu button - only visible on small screens */} {onMenuClick && ( )} -
+ ); } diff --git a/frontend/src/components/VisualizerView.tsx b/frontend/src/components/VisualizerView.tsx index d95c689..0f83ff0 100644 --- a/frontend/src/components/VisualizerView.tsx +++ b/frontend/src/components/VisualizerView.tsx @@ -42,9 +42,10 @@ export function VisualizerView({ packets, contacts, config }: VisualizerViewProp
Mesh Visualizer diff --git a/frontend/src/components/repeater/RepeaterAclPane.tsx b/frontend/src/components/repeater/RepeaterAclPane.tsx index 454a63a..af22e78 100644 --- a/frontend/src/components/repeater/RepeaterAclPane.tsx +++ b/frontend/src/components/repeater/RepeaterAclPane.tsx @@ -16,7 +16,7 @@ export function AclPane({ const permColor: Record = { 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', }; diff --git a/frontend/src/components/repeater/RepeaterConsolePane.tsx b/frontend/src/components/repeater/RepeaterConsolePane.tsx index 0d37ac2..87e45ce 100644 --- a/frontend/src/components/repeater/RepeaterConsolePane.tsx +++ b/frontend/src/components/repeater/RepeaterConsolePane.tsx @@ -40,6 +40,8 @@ export function ConsolePane({
{history.length === 0 && (

Type a CLI command below...

@@ -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" /> diff --git a/frontend/src/components/repeater/RepeaterNeighborsPane.tsx b/frontend/src/components/repeater/RepeaterNeighborsPane.tsx index 53c4b8e..6013102 100644 --- a/frontend/src/components/repeater/RepeaterNeighborsPane.tsx +++ b/frontend/src/components/repeater/RepeaterNeighborsPane.tsx @@ -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 ( {n.name || n.pubkey_prefix} diff --git a/frontend/src/components/repeater/RepeaterRadioSettingsPane.tsx b/frontend/src/components/repeater/RepeaterRadioSettingsPane.tsx index 4d18d90..8f9a51e 100644 --- a/frontend/src/components/repeater/RepeaterRadioSettingsPane.tsx +++ b/frontend/src/components/repeater/RepeaterRadioSettingsPane.tsx @@ -66,7 +66,7 @@ export function RadioSettingsPane({ (drift: {clockDrift.text}) diff --git a/frontend/src/components/repeater/repeaterPaneShared.tsx b/frontend/src/components/repeater/repeaterPaneShared.tsx index fc98f0d..8e725d6 100644 --- a/frontend/src/components/repeater/repeaterPaneShared.tsx +++ b/frontend/src/components/repeater/repeaterPaneShared.tsx @@ -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}`} > @@ -141,7 +142,7 @@ export function SettingsBotSection({ return (
-

+

Experimental: 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({

{ if ((e.target as HTMLElement).closest('input, button')) return; setExpandedBotId(expandedBotId === bot.id ? null : bot.id); }} > - + @@ -211,6 +216,9 @@ export function SettingsBotSection({ ) : ( { e.stopPropagation(); handleStartEditingName(bot); @@ -244,8 +252,9 @@ export function SettingsBotSection({ handleDeleteBot(bot.id); }} title="Delete bot" + aria-label="Delete bot" > - πŸ—‘ +
@@ -303,7 +312,11 @@ export function SettingsBotSection({

- {error &&
{error}
} + {error && ( +
+ {error} +
+ )} - {error &&
{error}
} + {error && ( +
+ {error} +
+ )}
); } diff --git a/frontend/src/components/settings/SettingsDatabaseSection.tsx b/frontend/src/components/settings/SettingsDatabaseSection.tsx index c2d1ac0..2f29352 100644 --- a/frontend/src/components/settings/SettingsDatabaseSection.tsx +++ b/frontend/src/components/settings/SettingsDatabaseSection.tsx @@ -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" />
@@ -271,7 +273,11 @@ export function SettingsDatabaseSection({

- {error &&
{error}
} + {error && ( +
+ {error} +
+ )}
- {error &&
{error}
} + {error && ( +
+ {error} +
+ )}
); } diff --git a/frontend/src/components/settings/SettingsMqttSection.tsx b/frontend/src/components/settings/SettingsMqttSection.tsx index 9418077..5f6fd99 100644 --- a/frontend/src/components/settings/SettingsMqttSection.tsx +++ b/frontend/src/components/settings/SettingsMqttSection.tsx @@ -125,10 +125,13 @@ export function SettingsMqttSection({
- {error &&
{error}
} + {error && ( +
+ {error} +
+ )}
); } diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx index c0fa566..10ead8a 100644 --- a/frontend/src/components/settings/SettingsRadioSection.tsx +++ b/frontend/src/components/settings/SettingsRadioSection.tsx @@ -276,7 +276,11 @@ export function SettingsRadioSection({
- {error &&
{error}
} + {error && ( +
+ {error} +
+ )}
- Decrypted - {stats.decrypted_packets} + Decrypted + {stats.decrypted_packets}
Undecrypted diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx index 42b1ef4..401628a 100644 --- a/frontend/src/components/ui/sonner.tsx +++ b/frontend/src/components/ui/sonner.tsx @@ -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} diff --git a/frontend/src/index.css b/frontend/src/index.css index 0c75738..379fb47 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/utils/a11y.ts b/frontend/src/utils/a11y.ts new file mode 100644 index 0000000..c4f0f32 --- /dev/null +++ b/frontend/src/utils/a11y.ts @@ -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(); + } +}