diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 000000000..4ae2350f3 Binary files /dev/null and b/bun.lockb differ diff --git a/src/components/Agents.tsx b/src/components/Agents.tsx index 795071786..c0f041f5b 100644 --- a/src/components/Agents.tsx +++ b/src/components/Agents.tsx @@ -354,8 +354,8 @@ export const Agents: React.FC = () => { - diff --git a/src/components/CCAgents.tsx b/src/components/CCAgents.tsx index f72b154ee..8cda1a8d5 100644 --- a/src/components/CCAgents.tsx +++ b/src/components/CCAgents.tsx @@ -469,6 +469,7 @@ export const CCAgents: React.FC = ({ onBack, className }) => { variant="outline" onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} + aria-label={`Go to previous page (page ${currentPage - 1} of ${totalPages})`} > Previous @@ -480,6 +481,7 @@ export const CCAgents: React.FC = ({ onBack, className }) => { variant="outline" onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} + aria-label={`Go to next page (page ${currentPage + 1} of ${totalPages})`} > Next diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index a9b9e5891..e04521f4b 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -28,7 +28,7 @@ import { SplitPane } from "@/components/ui/split-pane"; import { WebviewPreview } from "./WebviewPreview"; import type { ClaudeStreamMessage } from "./AgentExecution"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { useTrackEvent, useComponentMetrics, useWorkflowTracking } from "@/hooks"; +import { useTrackEvent, useComponentMetrics, useWorkflowTracking, useScreenReaderAnnouncements } from "@/hooks"; import { SessionPersistenceService } from "@/services/sessionPersistence"; interface ClaudeCodeSessionProps { @@ -140,6 +140,15 @@ export const ClaudeCodeSession: React.FC = ({ // const aiTracking = useAIInteractionTracking('sonnet'); // Default model const workflowTracking = useWorkflowTracking('claude_session'); + // Screen reader announcements + const { + announceClaudeStarted, + announceClaudeFinished, + announceAssistantMessage, + announceToolExecution, + announceToolCompleted + } = useScreenReaderAnnouncements(); + // Call onProjectPathChange when component mounts with initial path useEffect(() => { if (onProjectPathChange && projectPath) { @@ -480,6 +489,8 @@ export const ClaudeCodeSession: React.FC = ({ setError(null); hasActiveSessionRef.current = true; + // Don't announce "Claude says" here - wait for actual content to arrive + // For resuming sessions, ensure we have the session ID if (effectiveSession && !claudeSessionId) { setClaudeSessionId(effectiveSession.id); @@ -570,6 +581,60 @@ export const ClaudeCodeSession: React.FC = ({ } }); + // Helper to find tool name by tool use ID from previous messages + function findToolNameById(toolUseId: string): string | null { + // Search backwards through messages for the tool use + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.type === 'assistant' && msg.message?.content) { + const toolUse = msg.message.content.find((c: any) => + c.type === 'tool_use' && c.id === toolUseId + ); + if (toolUse?.name) { + return toolUse.name; + } + } + } + return null; + } + + // Helper to announce incoming messages to screen readers + function announceIncomingMessage(message: ClaudeStreamMessage) { + if (message.type === 'assistant' && message.message?.content) { + // Announce tool execution + const toolUses = message.message.content.filter((c: any) => c.type === 'tool_use'); + toolUses.forEach((toolUse: any) => { + const toolName = toolUse.name || 'unknown tool'; + const description = toolUse.input?.description || + toolUse.input?.command || + toolUse.input?.file_path || + toolUse.input?.pattern || + toolUse.input?.prompt?.substring(0, 50); + announceToolExecution(toolName, description); + }); + + // Announce text content + const textContent = message.message.content + .filter((c: any) => c.type === 'text') + .map((c: any) => typeof c.text === 'string' ? c.text : (c.text?.text || '')) + .join(' ') + .trim(); + + if (textContent) { + announceAssistantMessage(textContent); + } + } else if (message.type === 'system') { + // Announce system messages if they have meaningful content + if (message.subtype === 'init') { + // Don't announce init messages as they're just setup + return; + } else if (message.result || message.error) { + const content = message.result || message.error || 'System message received'; + announceAssistantMessage(content); + } + } + } + // Helper to process any JSONL stream message string function handleStreamMessage(payload: string) { try { @@ -581,6 +646,9 @@ export const ClaudeCodeSession: React.FC = ({ const message = JSON.parse(payload) as ClaudeStreamMessage; + // Announce incoming messages to screen readers + announceIncomingMessage(message); + // Track enhanced tool execution if (message.type === 'assistant' && message.message?.content) { const toolUses = message.message.content.filter((c: any) => c.type === 'tool_use'); @@ -609,6 +677,14 @@ export const ClaudeCodeSession: React.FC = ({ const toolResults = message.message.content.filter((c: any) => c.type === 'tool_result'); toolResults.forEach((result: any) => { const isError = result.is_error || false; + + // Announce tool completion + if (result.tool_use_id) { + // Try to find the tool name from previous messages + const toolName = findToolNameById(result.tool_use_id) || 'Tool'; + // announceToolCompleted(toolName, !isError); // Disabled to prevent interrupting other announcements + } + // Note: We don't have execution time here, but we can track success/failure if (isError) { sessionMetrics.current.toolsFailed += 1; @@ -660,6 +736,9 @@ export const ClaudeCodeSession: React.FC = ({ hasActiveSessionRef.current = false; isListeningRef.current = false; // Reset listening state + // Announce that Claude has finished + announceClaudeFinished(); + // Track enhanced session stopped metrics when session completes if (effectiveSession && claudeSessionId) { const sessionStartTimeValue = messages.length > 0 ? messages[0].timestamp || Date.now() : Date.now(); @@ -1333,10 +1412,13 @@ export const ClaudeCodeSession: React.FC = ({ animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} className="fixed bottom-24 left-1/2 -translate-x-1/2 z-30 w-full max-w-3xl px-4" + role="region" + aria-label="Prompt queue" + aria-live="polite" >
-
+
Queued Prompts ({queuedPrompts.length})
@@ -1344,21 +1426,28 @@ export const ClaudeCodeSession: React.FC = ({ whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }} > -
- {!queuedPromptsCollapsed && queuedPrompts.map((queuedPrompt, index) => ( - + {!queuedPromptsCollapsed && ( +
    + {queuedPrompts.map((queuedPrompt, index) => ( +
    #{index + 1} @@ -1377,12 +1466,15 @@ export const ClaudeCodeSession: React.FC = ({ size="icon" className="h-6 w-6 flex-shrink-0" onClick={() => setQueuedPrompts(prev => prev.filter(p => p.id !== queuedPrompt.id))} + aria-label={`Remove queued prompt: ${queuedPrompt.prompt.slice(0, 50)}${queuedPrompt.prompt.length > 50 ? '...' : ''}`} > - - ))} + + ))} +
+ )}
)} @@ -1431,7 +1523,8 @@ export const ClaudeCodeSession: React.FC = ({ }} className="px-3 py-2 hover:bg-accent rounded-none" > - + @@ -1464,7 +1557,8 @@ export const ClaudeCodeSession: React.FC = ({ }} className="px-3 py-2 hover:bg-accent rounded-none" > - + @@ -1496,6 +1590,9 @@ export const ClaudeCodeSession: React.FC = ({ size="icon" onClick={() => setShowTimeline(!showTimeline)} className="h-9 w-9 text-muted-foreground hover:text-foreground" + aria-label="Session timeline" + aria-haspopup="dialog" + aria-expanded={showTimeline} > @@ -1514,6 +1611,8 @@ export const ClaudeCodeSession: React.FC = ({ variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-foreground" + aria-label="Copy Conversation" + aria-haspopup="menu" > @@ -1556,6 +1655,8 @@ export const ClaudeCodeSession: React.FC = ({ size="icon" onClick={() => setShowSettings(!showSettings)} className="h-8 w-8 text-muted-foreground hover:text-foreground" + aria-label="Checkpoint Settings" + aria-haspopup="dialog" > diff --git a/src/components/CustomTitlebar.tsx b/src/components/CustomTitlebar.tsx index 3342959b0..ac3e3dfd6 100644 --- a/src/components/CustomTitlebar.tsx +++ b/src/components/CustomTitlebar.tsx @@ -91,6 +91,7 @@ export const CustomTitlebar: React.FC = ({ }} className="group relative w-3 h-3 rounded-full bg-red-500 hover:bg-red-600 transition-all duration-200 flex items-center justify-center tauri-no-drag" title="Close" + aria-label="Close window" > {isHovered && ( @@ -105,6 +106,7 @@ export const CustomTitlebar: React.FC = ({ }} className="group relative w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-600 transition-all duration-200 flex items-center justify-center tauri-no-drag" title="Minimize" + aria-label="Minimize window" > {isHovered && ( @@ -119,6 +121,7 @@ export const CustomTitlebar: React.FC = ({ }} className="group relative w-3 h-3 rounded-full bg-green-500 hover:bg-green-600 transition-all duration-200 flex items-center justify-center tauri-no-drag" title="Maximize" + aria-label="Maximize window" > {isHovered && ( @@ -146,6 +149,7 @@ export const CustomTitlebar: React.FC = ({ whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }} className="p-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors tauri-no-drag" + aria-label="Agents manager" > @@ -159,6 +163,7 @@ export const CustomTitlebar: React.FC = ({ whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }} className="p-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors tauri-no-drag" + aria-label="Usage dashboard" > @@ -178,6 +183,7 @@ export const CustomTitlebar: React.FC = ({ whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }} className="p-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors tauri-no-drag" + aria-label="Settings" > @@ -192,13 +198,19 @@ export const CustomTitlebar: React.FC = ({ whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }} className="p-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors flex items-center gap-1" + aria-label="More options" + aria-expanded={isDropdownOpen} > {isDropdownOpen && ( -
+
{onClaudeClick && ( @@ -910,6 +911,9 @@ const FloatingPromptInputInner = ( size="sm" onClick={() => setModelPickerOpen(!modelPickerOpen)} className="gap-2" + aria-label={`Select model, currently ${selectedModelData.name}`} + aria-expanded={modelPickerOpen} + aria-haspopup="listbox" > {selectedModelData.icon} @@ -931,6 +935,9 @@ const FloatingPromptInputInner = ( "hover:bg-accent", selectedModel === model.id && "bg-accent" )} + role="option" + aria-selected={selectedModel === model.id} + aria-label={`Select ${model.name} model`} >
@@ -965,6 +972,9 @@ const FloatingPromptInputInner = ( size="sm" onClick={() => setThinkingModePickerOpen(!thinkingModePickerOpen)} className="gap-2" + aria-label={`Select thinking mode: ${THINKING_MODES.find(m => m.id === selectedThinkingMode)?.name} - ${THINKING_MODES.find(m => m.id === selectedThinkingMode)?.description}`} + aria-expanded={!thinkingModePickerOpen} + aria-haspopup="listbox" > m.id === selectedThinkingMode)?.color}> {THINKING_MODES.find(m => m.id === selectedThinkingMode)?.icon} @@ -1029,6 +1039,7 @@ const FloatingPromptInputInner = ( disabled={!prompt.trim() || disabled} size="default" className="min-w-[60px]" + aria-label={isLoading ? "Cancel current request" : "Send message"} > {isLoading ? (
@@ -1083,6 +1094,8 @@ const FloatingPromptInputInner = ( size="sm" disabled={disabled} className="h-9 px-2 hover:bg-accent/50 gap-1" + aria-label={`Select model: ${selectedModelData.name} - ${selectedModelData.description}`} + aria-haspopup="menu" > {selectedModelData.icon} @@ -1114,6 +1127,7 @@ const FloatingPromptInputInner = ( "hover:bg-accent", selectedModel === model.id && "bg-accent" )} + aria-label={`Select ${model.name} model: ${model.description}`} >
@@ -1149,6 +1163,8 @@ const FloatingPromptInputInner = ( size="sm" disabled={disabled} className="h-9 px-2 hover:bg-accent/50 gap-1" + aria-label={`Select thinking mode: ${THINKING_MODES.find(m => m.id === selectedThinkingMode)?.name} - ${THINKING_MODES.find(m => m.id === selectedThinkingMode)?.description}`} + aria-haspopup="menu" > m.id === selectedThinkingMode)?.color}> {THINKING_MODES.find(m => m.id === selectedThinkingMode)?.icon} @@ -1180,6 +1196,7 @@ const FloatingPromptInputInner = ( "hover:bg-accent", selectedThinkingMode === mode.id && "bg-accent" )} + aria-label={`Select ${mode.name} thinking mode: ${mode.description}`} > {mode.icon} @@ -1245,6 +1262,7 @@ const FloatingPromptInputInner = ( onClick={() => setIsExpanded(true)} disabled={disabled} className="h-8 w-8 hover:bg-accent/50 transition-colors" + aria-label="Expand prompt editor" > @@ -1265,6 +1283,7 @@ const FloatingPromptInputInner = ( "h-8 w-8 transition-all", prompt.trim() && !isLoading && "shadow-sm" )} + aria-label={isLoading ? "Stop generation" : "Send message"} > {isLoading ? ( diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 9575c015c..f94dd4150 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -105,6 +105,20 @@ export const SessionList: React.FC = ({ "p-3 hover:bg-accent/50 transition-all duration-200 cursor-pointer group h-full", session.todo_data && "bg-primary/5" )} + role="button" + tabIndex={0} + aria-label={`Open session from ${session.message_timestamp + ? new Date(session.message_timestamp).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }) + : new Date(session.created_at * 1000).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }) + }${session.first_message ? `. First message: ${truncateText(getFirstLine(session.first_message), 60)}` : '. No messages yet'}${session.todo_data ? '. Contains todo items.' : ''}`} onClick={() => { // Emit a special event for Claude Code session navigation const event = new CustomEvent('claude-session-selected', { @@ -113,6 +127,16 @@ export const SessionList: React.FC = ({ window.dispatchEvent(event); onSessionClick?.(session); }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + const event = new CustomEvent('claude-session-selected', { + detail: { session, projectPath } + }); + window.dispatchEvent(event); + onSessionClick?.(session); + } + }} >
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 06d338a0c..b66bdbbf7 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -387,8 +387,9 @@ export const Settings: React.FC = ({ {/* Content */} {loading ? ( -
+
+ Loading settings...
) : (
@@ -633,7 +634,7 @@ export const Settings: React.FC = ({
-

+

How long to retain chat transcripts locally (default: 30 days)

@@ -648,6 +649,7 @@ export const Settings: React.FC = ({ updateSetting("cleanupPeriodDays", value); }} className="w-24" + aria-describedby="cleanup-description" />
@@ -792,6 +794,7 @@ export const Settings: React.FC = ({ size="sm" onClick={() => addPermissionRule("allow")} className="gap-2 hover:border-green-500/50 hover:text-green-500" + aria-label="Add new allow rule" > Add Rule @@ -816,12 +819,14 @@ export const Settings: React.FC = ({ value={rule.value} onChange={(e) => updatePermissionRule("allow", rule.id, e.target.value)} className="flex-1" + aria-label="Allow rule pattern" /> @@ -840,6 +845,7 @@ export const Settings: React.FC = ({ size="sm" onClick={() => addPermissionRule("deny")} className="gap-2 hover:border-red-500/50 hover:text-red-500" + aria-label="Add new deny rule" > Add Rule @@ -864,12 +870,14 @@ export const Settings: React.FC = ({ value={rule.value} onChange={(e) => updatePermissionRule("deny", rule.id, e.target.value)} className="flex-1" + aria-label="Deny rule pattern" /> @@ -935,6 +943,7 @@ export const Settings: React.FC = ({ value={envVar.key} onChange={(e) => updateEnvVar(envVar.id, "key", e.target.value)} className="flex-1 font-mono text-sm" + aria-label="Environment variable key" /> = = ({ value={envVar.value} onChange={(e) => updateEnvVar(envVar.id, "value", e.target.value)} className="flex-1 font-mono text-sm" + aria-label="Environment variable value" /> diff --git a/src/components/StorageTab.tsx b/src/components/StorageTab.tsx index aa4071cb3..be887ca03 100644 --- a/src/components/StorageTab.tsx +++ b/src/components/StorageTab.tsx @@ -529,8 +529,9 @@ export const StorageTab: React.FC = () => { onClick={() => loadTableData(currentPage - 1)} disabled={currentPage === 1} className="h-7 text-xs" + aria-label={`Go to previous page (page ${currentPage - 1} of ${tableData?.total_pages || 1})`} > - +
diff --git a/src/components/StreamMessage.tsx b/src/components/StreamMessage.tsx index ae43a5934..475a42e77 100644 --- a/src/components/StreamMessage.tsx +++ b/src/components/StreamMessage.tsx @@ -118,6 +118,7 @@ const StreamMessageComponent: React.FC = ({ message, classNa
+

Assistant Response

{msg.content && Array.isArray(msg.content) && msg.content.map((content: any, idx: number) => { // Text content - render as markdown if (content.type === "text") { @@ -331,6 +332,7 @@ const StreamMessageComponent: React.FC = ({ message, classNa
+

User Prompt

{/* Handle content that is a simple string (e.g. from user commands) */} {(typeof msg.content === 'string' || (msg.content && !Array.isArray(msg.content))) && ( (() => { diff --git a/src/components/TabManager.tsx b/src/components/TabManager.tsx index 5f63badba..31285a6e3 100644 --- a/src/components/TabManager.tsx +++ b/src/components/TabManager.tsx @@ -75,6 +75,10 @@ const TabItem: React.FC = ({ tab, isActive, onClose, onClick, isDr isDragging && "bg-card border-primary/50 shadow-sm z-50", "min-w-[120px] max-w-[220px] h-8 px-3" )} + role="tab" + aria-selected={isActive} + aria-label={`${tab.title} ${tab.type} tab`} + tabIndex={isActive ? 0 : -1} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onClick={() => onClick(tab.id)} @@ -103,6 +107,7 @@ const TabItem: React.FC = ({ tab, isActive, onClose, onClick, isDr )}
@@ -120,9 +125,10 @@ const TabItem: React.FC = ({ tab, isActive, onClose, onClick, isDr (isHovered || isActive) ? "opacity-100" : "opacity-0" )} title={`Close ${tab.title}`} + aria-label={`Close ${tab.title} tab`} tabIndex={-1} > - +
@@ -395,6 +405,7 @@ export const TabManager: React.FC = ({ className }) => { "bg-background/80 backdrop-blur-sm shadow-sm border border-border/50" )} title="Scroll tabs right" + aria-label="Scroll tabs to the right" > diff --git a/src/components/TimelineNavigator.tsx b/src/components/TimelineNavigator.tsx index 2cc0ce154..2fd149ded 100644 --- a/src/components/TimelineNavigator.tsx +++ b/src/components/TimelineNavigator.tsx @@ -281,11 +281,12 @@ export const TimelineNavigator: React.FC = ({ size="icon" className="h-6 w-6 -ml-1" onClick={() => toggleNodeExpansion(node.checkpoint.id)} + aria-label={isExpanded ? "Collapse checkpoint" : "Expand checkpoint"} > {isExpanded ? ( - + ) : ( - + )} )} @@ -348,8 +349,9 @@ export const TimelineNavigator: React.FC = ({ e.stopPropagation(); handleRestoreCheckpoint(node.checkpoint); }} + aria-label="Restore to this checkpoint" > - + Restore to this checkpoint @@ -367,8 +369,9 @@ export const TimelineNavigator: React.FC = ({ e.stopPropagation(); handleFork(node.checkpoint); }} + aria-label="Fork from this checkpoint" > - + Fork from this checkpoint @@ -386,8 +389,9 @@ export const TimelineNavigator: React.FC = ({ e.stopPropagation(); handleCompare(node.checkpoint); }} + aria-label="Compare with another checkpoint" > - + Compare with another checkpoint diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index eeb5bc743..caea5da96 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -85,7 +85,7 @@ export const Topbar: React.FC = ({ const StatusIndicator = () => { if (checking) { return ( -
+
Checking...
@@ -100,8 +100,11 @@ export const Topbar: React.FC = ({ size="sm" className="h-auto py-1 px-2 hover:bg-accent" onClick={onSettingsClick} + aria-label={versionStatus.is_installed + ? `Claude Code is installed, version ${versionStatus.version || 'unknown'}. Click to open settings.` + : "Claude Code not found. Click to configure installation."} > -
+
= React.memo(({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="sticky bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-background to-transparent" + role="status" + aria-live="polite" + aria-label="Claude is processing your request" >
diff --git a/src/components/claude-code-session/PromptQueue.tsx b/src/components/claude-code-session/PromptQueue.tsx index b2b2f546e..cdff4326f 100644 --- a/src/components/claude-code-session/PromptQueue.tsx +++ b/src/components/claude-code-session/PromptQueue.tsx @@ -30,20 +30,23 @@ export const PromptQueue: React.FC = React.memo(({ animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} className={cn("border-t bg-muted/20", className)} + role="region" + aria-label="Prompt queue" + aria-live="polite" >
- Queued Prompts + Queued Prompts {queuedPrompts.length}
-
+
    {queuedPrompts.map((queuedPrompt, index) => ( - = React.memo(({ >
    {queuedPrompt.model === "opus" ? ( - + ) : ( - + )}
    @@ -71,13 +74,14 @@ export const PromptQueue: React.FC = React.memo(({ size="icon" className="h-6 w-6 flex-shrink-0" onClick={() => onRemove(queuedPrompt.id)} + aria-label={`Remove queued prompt: ${queuedPrompt.prompt.slice(0, 50)}${queuedPrompt.prompt.length > 50 ? '...' : ''}`} > - +
    + ))}
    -
+
); diff --git a/src/components/claude-code-session/SessionHeader.tsx b/src/components/claude-code-session/SessionHeader.tsx index b48a24823..968679460 100644 --- a/src/components/claude-code-session/SessionHeader.tsx +++ b/src/components/claude-code-session/SessionHeader.tsx @@ -64,6 +64,7 @@ export const SessionHeader: React.FC = React.memo(({ size="icon" onClick={onBack} className="h-8 w-8" + aria-label="Go back to session list" > @@ -107,8 +108,8 @@ export const SessionHeader: React.FC = React.memo(({ open={copyPopoverOpen} onOpenChange={setCopyPopoverOpen} trigger={ - } content={ @@ -143,14 +144,15 @@ export const SessionHeader: React.FC = React.memo(({ "h-8 w-8 transition-colors", showTimeline && "bg-accent text-accent-foreground" )} + aria-label={showTimeline ? "Hide timeline" : "Show timeline"} > - +