fix(mobile): harden touch accessibility

This commit is contained in:
Saravana Achu Mac 2026-05-05 10:29:54 -07:00
parent e0adedc739
commit f9903fec7c
14 changed files with 155 additions and 87 deletions

View File

@ -6,6 +6,7 @@ import { useNotesStore, type NotesState } from '../../store/notes-store';
import { useWorkspaceStore, type WorkspaceState } from '../../store/workspace-store';
import { useIntakeStore, type IntakeState } from '../../store/intake-store';
import { submitIntake, type IntakeSubmitResult } from '../../api/intake';
import { buttonA11y, textInputA11y, dynamicType } from '../../lib/accessibility';
import { colors } from '../../theme';
type CaptureMode = 'text' | 'photo' | 'voice' | 'url' | 'scan' | 'paste';
@ -133,7 +134,7 @@ export default function CaptureScreen() {
const isActive = workspace.id === activeWorkspaceId;
return (
<Pressable
accessibilityLabel={`Select workspace ${workspace.name}`}
{...buttonA11y(`Select workspace ${workspace.name}`, { selected: isActive })}
key={workspace.id}
onPress={() => setActiveWorkspace(workspace.id)}
style={[styles.workspaceChip, isActive ? styles.workspaceChipActive : null]}
@ -153,7 +154,7 @@ export default function CaptureScreen() {
return (
<Pressable
key={m}
accessibilityLabel={`${label} capture mode: ${description}`}
{...buttonA11y(`${label} capture mode: ${description}`, { selected: isActive })}
onPress={() => { setMode(m); resetForm(); }}
style={[styles.modeCard, isActive ? styles.modeCardActive : null]}
>
@ -167,28 +168,28 @@ export default function CaptureScreen() {
{/* Mode-specific content */}
{mode === 'text' && (
<>
<TextInput value={title} onChangeText={setTitle} placeholder="Draft title" placeholderTextColor={colors.textTertiary} style={styles.input} />
<TextInput value={body} onChangeText={setBody} placeholder="Capture a thought, task, or note" placeholderTextColor={colors.textTertiary} style={[styles.input, styles.bodyInput]} multiline textAlignVertical="top" />
<TextInput {...textInputA11y('Draft title')} value={title} onChangeText={setTitle} placeholder="Draft title" placeholderTextColor={colors.textTertiary} style={styles.input} />
<TextInput {...textInputA11y('Draft body')} value={body} onChangeText={setBody} placeholder="Capture a thought, task, or note" placeholderTextColor={colors.textTertiary} style={[styles.input, styles.bodyInput]} multiline textAlignVertical="top" />
</>
)}
{mode === 'url' && (
<>
<TextInput value={urlInput} onChangeText={(t) => { setUrlInput(t); setIntakeResult(null); }} placeholder="https://example.com/article" placeholderTextColor={colors.textTertiary} style={styles.input} autoCapitalize="none" keyboardType="url" />
<TextInput {...textInputA11y('URL to process')} value={urlInput} onChangeText={(t) => { setUrlInput(t); setIntakeResult(null); }} placeholder="https://example.com/article" placeholderTextColor={colors.textTertiary} style={styles.input} autoCapitalize="none" keyboardType="url" />
{intakeResult ? (
<View style={styles.card}>
<Text style={styles.cardTitle}>Processing as {intakeResult.contentType}</Text>
<Text style={styles.cardBody}>Job: {intakeResult.jobId}</Text>
</View>
) : (
<Pressable accessibilityLabel="Process URL with AI" onPress={() => void handleUrlIntake()} disabled={busy || !urlInput.trim()} style={[styles.button, busy ? styles.buttonDisabled : null]}>
<Text style={styles.buttonText}>{busy ? 'Submitting...' : 'Process with AI'}</Text>
<Pressable {...buttonA11y('Process URL with AI', { disabled: busy || !urlInput.trim() })} onPress={() => void handleUrlIntake()} disabled={busy || !urlInput.trim()} style={[styles.button, (busy || !urlInput.trim()) ? styles.buttonDisabled : null]}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.buttonText}>{busy ? 'Submitting...' : 'Process with AI'}</Text>
</Pressable>
)}
{body ? (
<>
<TextInput value={title} onChangeText={setTitle} placeholder="Title" placeholderTextColor={colors.textTertiary} style={styles.input} />
<TextInput value={body} onChangeText={setBody} placeholder="Processed content" placeholderTextColor={colors.textTertiary} style={[styles.input, styles.bodyInput]} multiline textAlignVertical="top" />
<TextInput {...textInputA11y('Processed note title')} value={title} onChangeText={setTitle} placeholder="Title" placeholderTextColor={colors.textTertiary} style={styles.input} />
<TextInput {...textInputA11y('Processed note body')} value={body} onChangeText={setBody} placeholder="Processed content" placeholderTextColor={colors.textTertiary} style={[styles.input, styles.bodyInput]} multiline textAlignVertical="top" />
</>
) : null}
</>
@ -196,13 +197,13 @@ export default function CaptureScreen() {
{mode === 'paste' && (
<>
<Pressable accessibilityLabel="Read clipboard and clean text" onPress={handlePasteAndClean} disabled={busy} style={[styles.button, busy ? styles.buttonDisabled : null]}>
<Text style={styles.buttonText}>{busy ? 'Reading clipboard...' : 'Paste & Clean'}</Text>
<Pressable {...buttonA11y('Read clipboard and clean text', { disabled: busy })} onPress={handlePasteAndClean} disabled={busy} style={[styles.button, busy ? styles.buttonDisabled : null]}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.buttonText}>{busy ? 'Reading clipboard...' : 'Paste & Clean'}</Text>
</Pressable>
{body ? (
<>
<TextInput value={title} onChangeText={setTitle} placeholder="Title" placeholderTextColor={colors.textTertiary} style={styles.input} />
<TextInput value={body} onChangeText={setBody} placeholder="Cleaned content" placeholderTextColor={colors.textTertiary} style={[styles.input, styles.bodyInput]} multiline textAlignVertical="top" />
<TextInput {...textInputA11y('Pasted note title')} value={title} onChangeText={setTitle} placeholder="Title" placeholderTextColor={colors.textTertiary} style={styles.input} />
<TextInput {...textInputA11y('Pasted note body')} value={body} onChangeText={setBody} placeholder="Cleaned content" placeholderTextColor={colors.textTertiary} style={[styles.input, styles.bodyInput]} multiline textAlignVertical="top" />
</>
) : null}
</>
@ -212,8 +213,8 @@ export default function CaptureScreen() {
<View style={styles.card}>
<Text style={styles.cardTitle}>Voice-to-Note</Text>
<Text style={styles.cardBody}>Record audio and transcribe to text. Requires expo-av for audio recording.</Text>
<Pressable accessibilityLabel="Start voice recording" onPress={handleVoiceCapture} style={styles.button}>
<Text style={styles.buttonText}>Start Recording</Text>
<Pressable {...buttonA11y('Start voice recording')} onPress={handleVoiceCapture} style={styles.button}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.buttonText}>Start Recording</Text>
</Pressable>
</View>
)}
@ -222,8 +223,8 @@ export default function CaptureScreen() {
<View style={styles.card}>
<Text style={styles.cardTitle}>Screenshot-to-Note</Text>
<Text style={styles.cardBody}>Take a photo or select from gallery. Uses vision AI for OCR and text extraction.</Text>
<Pressable accessibilityLabel="Open camera for photo capture" onPress={handlePhotoCapture} style={styles.button}>
<Text style={styles.buttonText}>Open Camera</Text>
<Pressable {...buttonA11y('Open camera for photo capture')} onPress={handlePhotoCapture} style={styles.button}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.buttonText}>Open Camera</Text>
</Pressable>
</View>
)}
@ -232,8 +233,8 @@ export default function CaptureScreen() {
<View style={styles.card}>
<Text style={styles.cardTitle}>Document Scan</Text>
<Text style={styles.cardBody}>Photograph multiple pages of a document. Each page is processed with vision AI and combined into a single note.</Text>
<Pressable accessibilityLabel="Start document scan" onPress={handleScanCapture} style={styles.button}>
<Text style={styles.buttonText}>Start Scanning</Text>
<Pressable {...buttonA11y('Start document scan')} onPress={handleScanCapture} style={styles.button}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.buttonText}>Start Scanning</Text>
</Pressable>
</View>
)}
@ -244,12 +245,12 @@ export default function CaptureScreen() {
{/* Save button (shown when we have content to save) */}
{(mode === 'text' || body) && (
<Pressable
accessibilityLabel={activeWorkspaceId ? 'Save draft note' : 'Select workspace before saving'}
{...buttonA11y(activeWorkspaceId ? 'Save draft note' : 'Select workspace before saving', { disabled: !activeWorkspaceId || busy })}
onPress={handleSave}
disabled={!activeWorkspaceId || busy}
style={[styles.button, (!activeWorkspaceId || busy) ? styles.buttonDisabled : null]}
>
<Text style={styles.buttonText}>{busy ? 'Saving...' : activeWorkspaceId ? 'Save draft' : 'Select workspace'}</Text>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.buttonText}>{busy ? 'Saving...' : activeWorkspaceId ? 'Save draft' : 'Select workspace'}</Text>
</Pressable>
)}
{saved ? <Text style={styles.saved}>Draft saved to the product backend.</Text> : null}

View File

@ -1,6 +1,7 @@
import { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { buttonA11y, dynamicType } from '../../../lib/accessibility';
import { colors } from '../../../theme';
export default function ScanCaptureScreen() {
@ -13,9 +14,9 @@ export default function ScanCaptureScreen() {
<Text style={styles.hint}>Use your camera to scan a multi-page document. Text will be extracted via OCR.</Text>
<TouchableOpacity
{...buttonA11y(scanning ? 'Stop scanning' : 'Start scanning', { selected: scanning })}
style={[styles.scanBtn, scanning && styles.scanBtnActive]}
onPress={() => setScanning(!scanning)}
accessibilityLabel={scanning ? 'Stop scanning' : 'Start scanning'}
>
<Text style={styles.scanIcon}>{scanning ? '⏹' : '📄'}</Text>
<Text style={styles.scanLabel}>{scanning ? 'Stop' : 'Scan Document'}</Text>
@ -25,8 +26,8 @@ export default function ScanCaptureScreen() {
Camera-based document scanning will be available in a future release. For now, use the Text or URL capture modes.
</Text>
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()}>
<Text style={styles.backBtnText}>Go Back</Text>
<TouchableOpacity {...buttonA11y('Go back')} style={styles.backBtn} onPress={() => router.back()}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.backBtnText}>Go Back</Text>
</TouchableOpacity>
</View>
);

View File

@ -4,6 +4,7 @@ import { useRouter } from 'expo-router';
import { submitIntake, type IntakeSubmitResult } from '../../../api/intake';
import { useWorkspaceStore, type WorkspaceState } from '../../../store/workspace-store';
import { useIntakeStore, type IntakeState } from '../../../store/intake-store';
import { buttonA11y, textInputA11y, dynamicType } from '../../../lib/accessibility';
import { colors } from '../../../theme';
function classifyUrlLocal(url: string): string {
@ -63,7 +64,7 @@ export default function UrlCaptureScreen() {
onChangeText={(text) => { setUrl(text); setResult(null); setError(null); }}
autoCapitalize="none"
keyboardType="url"
accessibilityLabel="URL input"
{...textInputA11y('URL input')}
/>
{detectedType && (
@ -86,24 +87,24 @@ export default function UrlCaptureScreen() {
) : (
<View style={styles.buttonRow}>
<Pressable
{...buttonA11y('Process URL with AI', { disabled: busy || !url.trim() })}
style={[styles.btn, (busy || !url.trim()) && styles.btnDisabled]}
disabled={busy || !url.trim()}
onPress={() => void handleProcess()}
accessibilityLabel="Process URL with AI"
>
{busy ? (
<ActivityIndicator color={colors.textPrimary} size="small" />
) : (
<Text style={styles.btnText}>Process with AI</Text>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.btnText}>Process with AI</Text>
)}
</Pressable>
<Pressable
{...buttonA11y('Advanced intake options', { disabled: !url.trim() })}
style={styles.secondaryBtn}
disabled={!url.trim()}
onPress={handleAdvanced}
accessibilityLabel="Advanced intake options"
>
<Text style={styles.secondaryBtnText}>Options</Text>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.secondaryBtnText}>Options</Text>
</Pressable>
</View>
)}

View File

@ -1,6 +1,7 @@
import { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { buttonA11y, dynamicType } from '../../../lib/accessibility';
import { colors } from '../../../theme';
export default function VoiceCaptureScreen() {
@ -14,9 +15,9 @@ export default function VoiceCaptureScreen() {
<Text style={styles.hint}>Tap the microphone to start recording. Speech will be transcribed automatically.</Text>
<TouchableOpacity
{...buttonA11y(recording ? 'Stop recording' : 'Start recording', { selected: recording })}
style={[styles.micBtn, recording && styles.micBtnActive]}
onPress={() => setRecording(!recording)}
accessibilityLabel={recording ? 'Stop recording' : 'Start recording'}
>
<Text style={styles.micIcon}>{recording ? '⏹' : '🎙'}</Text>
<Text style={styles.micLabel}>{recording ? 'Stop' : 'Record'}</Text>
@ -27,8 +28,8 @@ export default function VoiceCaptureScreen() {
<View style={styles.transcriptBox}>
<Text style={styles.transcriptText}>{transcript}</Text>
</View>
<TouchableOpacity style={styles.saveBtn} onPress={() => router.back()}>
<Text style={styles.saveBtnText}>Save as Note</Text>
<TouchableOpacity {...buttonA11y('Save transcript as note')} style={styles.saveBtn} onPress={() => router.back()}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.saveBtnText}>Save as Note</Text>
</TouchableOpacity>
</>
)}

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { useInboxStore, type ActivityItem, type ApprovalItem, type InboxState } from '../../store/inbox-store';
import { buttonA11y, textInputA11y, dynamicType } from '../../lib/accessibility';
import { colors } from '../../theme';
export default function InboxScreen() {
@ -24,6 +25,7 @@ export default function InboxScreen() {
{isLoading ? <Text style={styles.empty}>Loading approvals</Text> : null}
{approvals.length > 0 ? (
<TextInput
{...textInputA11y('Review note')}
style={styles.reviewNoteInput}
placeholder="Optional review note…"
placeholderTextColor={colors.textSecondary}
@ -40,7 +42,7 @@ export default function InboxScreen() {
<Text style={styles.status}>Status: {item.status}</Text>
<View style={styles.actionRow}>
<Pressable
accessibilityLabel={`Approve ${item.title}`}
{...buttonA11y(`Approve ${item.title}`, { disabled: pendingApprovalId === item.id || item.status !== 'pending' })}
style={[
styles.approveButton,
pendingApprovalId === item.id || item.status !== 'pending' ? styles.buttonDisabled : null,
@ -57,12 +59,12 @@ export default function InboxScreen() {
}
}}
>
<Text style={styles.approveText}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.approveText}>
{pendingApprovalId === item.id ? 'Approving…' : 'Approve'}
</Text>
</Pressable>
<Pressable
accessibilityLabel={`Reject ${item.title}`}
{...buttonA11y(`Reject ${item.title}`, { disabled: pendingApprovalId === item.id || item.status !== 'pending' })}
style={[
styles.rejectButton,
pendingApprovalId === item.id || item.status !== 'pending' ? styles.buttonDisabled : null,
@ -79,7 +81,7 @@ export default function InboxScreen() {
}
}}
>
<Text style={styles.rejectText}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.rejectText}>
{pendingApprovalId === item.id ? 'Rejecting…' : 'Reject'}
</Text>
</Pressable>

View File

@ -5,6 +5,7 @@ import type { MobileNote } from '../../api/notes';
import { useNotesStore, type NotesState } from '../../store/notes-store';
import { useWorkspaceStore, type WorkspaceState } from '../../store/workspace-store';
import type { MobileWorkspace } from '../../api/workspaces';
import { buttonA11y } from '../../lib/accessibility';
import { colors } from '../../theme';
export default function HomeScreen() {
@ -41,7 +42,7 @@ export default function HomeScreen() {
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.workspaceRow}>
<Pressable
accessibilityLabel="Show all workspaces"
{...buttonA11y('Show all workspaces', { selected: activeWorkspaceId === null })}
onPress={() => setActiveWorkspace(null)}
style={[styles.workspaceChip, activeWorkspaceId === null ? styles.workspaceChipActive : null]}
>
@ -54,7 +55,7 @@ export default function HomeScreen() {
return (
<Pressable
accessibilityLabel={`Filter by ${workspace.name}`}
{...buttonA11y(`Filter by ${workspace.name}`, { selected: isActive })}
key={workspace.id}
onPress={() => setActiveWorkspace(workspace.id)}
style={[styles.workspaceChip, isActive ? styles.workspaceChipActive : null]}
@ -69,7 +70,7 @@ export default function HomeScreen() {
{isLoading ? <Text style={styles.empty}>Loading notes</Text> : null}
{recentNotes.map((note: (typeof recentNotes)[number]) => (
<Pressable accessibilityLabel={`Open note ${note.title}`} key={note.id} onPress={() => router.push(`/note/${note.id}`)} style={styles.card}>
<Pressable {...buttonA11y(`Open note ${note.title}`)} key={note.id} onPress={() => router.push(`/note/${note.id}`)} style={styles.card}>
<View style={styles.badgeRow}>
<Text style={styles.badge}>{note.workspace}</Text>
</View>

View File

@ -5,6 +5,7 @@ import type { MobileNote } from '../../api/notes';
import type { MobileWorkspace } from '../../api/workspaces';
import { useNotesStore, type NotesState } from '../../store/notes-store';
import { useWorkspaceStore, type WorkspaceState } from '../../store/workspace-store';
import { buttonA11y, textInputA11y } from '../../lib/accessibility';
import { colors } from '../../theme';
export default function SearchScreen() {
@ -39,7 +40,7 @@ export default function SearchScreen() {
<Text style={styles.title}>Search</Text>
<Text style={styles.scope}>Scope: {activeWorkspaceName ?? 'All workspaces'}</Text>
<TextInput
accessibilityLabel="Search notes"
{...textInputA11y('Search notes')}
value={query}
onChangeText={setQuery}
placeholder="Search notes"
@ -49,7 +50,7 @@ export default function SearchScreen() {
<View style={styles.list}>
{isLoading ? <Text style={styles.empty}>Loading searchable notes</Text> : null}
{results.map((note: MobileNote) => (
<Pressable accessibilityLabel={`Open note ${note.title}`} key={note.id} onPress={() => router.push(`/note/${note.id}`)} style={styles.row}>
<Pressable {...buttonA11y(`Open note ${note.title}`)} key={note.id} onPress={() => router.push(`/note/${note.id}`)} style={styles.row}>
<Text style={styles.rowText}>{note.title}</Text>
</Pressable>
))}

View File

@ -4,6 +4,7 @@ import { router } from 'expo-router';
import { useAuthStore, type AuthState } from '../../store/auth-store';
import { getFeedbackClient } from '../../lib/feedback-client';
import { APP_PLATFORM } from '../../lib/app-metadata';
import { buttonA11y, textInputA11y, dynamicType } from '../../lib/accessibility';
import { colors } from '../../theme';
type FeedbackType = 'bug' | 'feature' | 'praise' | 'other';
@ -50,14 +51,14 @@ export default function SettingsScreen() {
<Text style={styles.metaLabel}>Signed in as</Text>
<Text style={styles.metaValue}>{email ?? 'Unknown account'}</Text>
<Pressable
accessibilityLabel="Sign out"
{...buttonA11y('Sign out')}
style={styles.secondaryButton}
onPress={() => {
signOut();
router.replace('/auth');
}}
>
<Text style={styles.secondaryButtonText}>Sign out</Text>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.secondaryButtonText}>Sign out</Text>
</Pressable>
</View>
@ -68,7 +69,7 @@ export default function SettingsScreen() {
const isActive = type === feedbackType;
return (
<Pressable
accessibilityLabel={`Select ${type} feedback type`}
{...buttonA11y(`Select ${type} feedback type`, { selected: isActive })}
key={type}
style={[styles.typeChip, isActive ? styles.typeChipActive : null]}
onPress={() => setFeedbackType(type)}
@ -80,6 +81,7 @@ export default function SettingsScreen() {
</View>
<TextInput
{...textInputA11y('Feedback title')}
value={feedbackTitle}
onChangeText={setFeedbackTitle}
placeholder="Feedback title"
@ -87,6 +89,7 @@ export default function SettingsScreen() {
style={styles.input}
/>
<TextInput
{...textInputA11y('Feedback details')}
value={feedbackBody}
onChangeText={setFeedbackBody}
placeholder="Details (optional)"
@ -97,14 +100,14 @@ export default function SettingsScreen() {
/>
<Pressable
accessibilityLabel={isSubmitting ? 'Submitting feedback' : 'Submit feedback'}
{...buttonA11y(isSubmitting ? 'Submitting feedback' : 'Submit feedback', { disabled: feedbackTitle.trim().length === 0 || isSubmitting })}
style={[styles.primaryButton, feedbackTitle.trim().length === 0 || isSubmitting ? styles.buttonDisabled : null]}
disabled={feedbackTitle.trim().length === 0 || isSubmitting}
onPress={() => {
void submitFeedback();
}}
>
<Text style={styles.primaryButtonText}>{isSubmitting ? 'Submitting…' : 'Submit feedback'}</Text>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.primaryButtonText}>{isSubmitting ? 'Submitting…' : 'Submit feedback'}</Text>
</Pressable>
</View>
</ScrollView>

View File

@ -13,6 +13,7 @@ import { getSurveyClient } from '../lib/survey-client';
import { flushNoteQueue, getNoteQueueSize } from '../lib/offline-queue';
import type { InAppMessage } from '@bytelyst/broadcast-client';
import type { ActiveSurvey, Question, QuestionAnswer } from '@bytelyst/survey-client';
import { buttonA11y, textInputA11y, dynamicType } from '../lib/accessibility';
import { colors } from '../theme';
export default function RootLayout() {
@ -268,8 +269,8 @@ export default function RootLayout() {
{message.body ? <Text style={styles.bannerBody}>{message.body}</Text> : null}
</View>
{message.dismissible !== false ? (
<Pressable onPress={() => void dismissBroadcast(message.id)}>
<Text style={styles.bannerDismiss}>×</Text>
<Pressable {...buttonA11y(`Dismiss ${message.title}`)} onPress={() => void dismissBroadcast(message.id)}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.bannerDismiss}>×</Text>
</Pressable>
) : null}
</View>
@ -289,11 +290,11 @@ export default function RootLayout() {
<View style={styles.surveyPrompt}>
<Text style={styles.surveyPromptText}>{activeSurvey.title}</Text>
<View style={styles.surveyActions}>
<Pressable style={styles.primaryButton} onPress={() => void startSurvey()}>
<Text style={styles.primaryButtonText}>Start</Text>
<Pressable {...buttonA11y(`Start survey ${activeSurvey.title}`)} style={styles.primaryButton} onPress={() => void startSurvey()}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.primaryButtonText}>Start</Text>
</Pressable>
<Pressable style={styles.secondaryButton} onPress={() => void dismissSurvey()}>
<Text style={styles.secondaryButtonText}>Dismiss</Text>
<Pressable {...buttonA11y(`Dismiss survey ${activeSurvey.title}`)} style={styles.secondaryButton} onPress={() => void dismissSurvey()}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.secondaryButtonText}>Dismiss</Text>
</Pressable>
</View>
</View>
@ -322,10 +323,11 @@ export default function RootLayout() {
{currentQuestion.options.map((option) => (
<Pressable
key={option.id}
{...buttonA11y(`Answer ${option.text}`)}
style={styles.optionButton}
onPress={() => void submitAnswer(option.id)}
>
<Text style={styles.optionText}>{option.emoji ? `${option.emoji} ` : ''}{option.text}</Text>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.optionText}>{option.emoji ? `${option.emoji} ` : ''}{option.text}</Text>
</Pressable>
))}
</ScrollView>
@ -335,14 +337,15 @@ export default function RootLayout() {
{ length: (currentQuestion.maxValue ?? 5) - (currentQuestion.minValue ?? 1) + 1 },
(_, index) => (currentQuestion.minValue ?? 1) + index,
).map((value) => (
<Pressable key={value} style={styles.ratingButton} onPress={() => void submitAnswer(String(value))}>
<Text style={styles.ratingText}>{value}</Text>
<Pressable key={value} {...buttonA11y(`Rate ${value}`)} style={styles.ratingButton} onPress={() => void submitAnswer(String(value))}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.ratingText}>{value}</Text>
</Pressable>
))}
</View>
) : (
<>
<TextInput
{...textInputA11y('Survey answer')}
value={textAnswer}
onChangeText={setTextAnswer}
placeholder="Your answer"
@ -351,17 +354,18 @@ export default function RootLayout() {
multiline={currentQuestion?.type === 'text_long'}
/>
<Pressable
{...buttonA11y('Submit survey answer', { disabled: textAnswer.trim().length === 0 })}
style={[styles.primaryButton, textAnswer.trim().length === 0 ? styles.disabledButton : null]}
disabled={textAnswer.trim().length === 0}
onPress={() => void submitAnswer(textAnswer.trim())}
>
<Text style={styles.primaryButtonText}>Next</Text>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.primaryButtonText}>Next</Text>
</Pressable>
</>
)}
<Pressable style={styles.secondaryButton} onPress={() => void dismissSurvey()}>
<Text style={styles.secondaryButtonText}>Close</Text>
<Pressable {...buttonA11y('Close survey')} style={styles.secondaryButton} onPress={() => void dismissSurvey()}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.secondaryButtonText}>Close</Text>
</Pressable>
</View>
</View>

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { router } from 'expo-router';
import { useAuthStore, type AuthState } from '../store/auth-store';
import { buttonA11y, textInputA11y, dynamicType } from '../lib/accessibility';
import { colors } from '../theme';
export default function AuthScreen() {
@ -27,6 +28,7 @@ export default function AuthScreen() {
<Text style={styles.subtitle}>{mode === 'signin' ? 'Sign in to continue' : 'Create your account'}</Text>
{mode === 'register' ? (
<TextInput
{...textInputA11y('Display name')}
autoCapitalize="words"
onChangeText={(value: string) => {
setErrorMessage(null);
@ -39,6 +41,7 @@ export default function AuthScreen() {
/>
) : null}
<TextInput
{...textInputA11y('Email address')}
autoCapitalize="none"
keyboardType="email-address"
onChangeText={(value: string) => {
@ -51,6 +54,7 @@ export default function AuthScreen() {
value={email}
/>
<TextInput
{...textInputA11y('Password')}
onChangeText={(value: string) => {
setErrorMessage(null);
setPassword(value);
@ -63,7 +67,8 @@ export default function AuthScreen() {
/>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
<Pressable
accessibilityLabel={mode === 'signin' ? 'Sign in' : 'Register account'}
{...buttonA11y(mode === 'signin' ? 'Sign in' : 'Register account', { disabled: isLoading })}
disabled={isLoading}
onPress={async () => {
const didAuthenticate =
mode === 'signin'
@ -81,21 +86,21 @@ export default function AuthScreen() {
: 'Registration failed. Check your details or connection and try again.',
);
}}
style={styles.button}
style={[styles.button, isLoading ? styles.buttonDisabled : null]}
>
<Text style={styles.buttonText}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.buttonText}>
{isLoading ? (mode === 'signin' ? 'Signing in…' : 'Creating account…') : mode === 'signin' ? 'Sign in' : 'Register'}
</Text>
</Pressable>
<Pressable
accessibilityLabel={mode === 'signin' ? 'Switch to register mode' : 'Switch to sign in mode'}
{...buttonA11y(mode === 'signin' ? 'Switch to register mode' : 'Switch to sign in mode')}
onPress={() => {
setErrorMessage(null);
setMode((current) => (current === 'signin' ? 'register' : 'signin'));
}}
style={styles.modeSwitch}
>
<Text style={styles.modeSwitchText}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.modeSwitchText}>
{mode === 'signin' ? 'Need an account? Register' : 'Already have an account? Sign in'}
</Text>
</Pressable>
@ -137,6 +142,9 @@ const styles = StyleSheet.create({
paddingVertical: 14,
alignItems: 'center',
},
buttonDisabled: {
opacity: 0.55,
},
buttonText: {
color: colors.textPrimary,
fontWeight: '700',

View File

@ -6,6 +6,7 @@ import { listPromptTemplates, type MobilePromptTemplate } from '../api/note-prom
import { useWorkspaceStore, type WorkspaceState } from '../store/workspace-store';
import { useIntakeStore, type IntakeState } from '../store/intake-store';
import type { MobileWorkspace } from '../api/workspaces';
import { buttonA11y, dynamicType } from '../lib/accessibility';
import { colors } from '../theme';
type ContentTypeBadge = { label: string; color: string };
@ -157,7 +158,7 @@ export default function IntakeScreen() {
<Text style={styles.label}>Template</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chipRow}>
<Pressable
accessibilityLabel="Auto-select template"
{...buttonA11y('Auto-select template', { selected: selectedTemplate === null })}
onPress={() => setSelectedTemplate(null)}
style={[styles.chip, selectedTemplate === null ? styles.chipActive : null]}
>
@ -168,7 +169,7 @@ export default function IntakeScreen() {
{templates.slice(0, 8).map((t) => (
<Pressable
key={t.id}
accessibilityLabel={`Select template ${t.name}`}
{...buttonA11y(`Select template ${t.name}`, { selected: selectedTemplate === t.slug })}
onPress={() => setSelectedTemplate(t.slug)}
style={[styles.chip, selectedTemplate === t.slug ? styles.chipActive : null]}
>
@ -198,7 +199,7 @@ export default function IntakeScreen() {
{/* Process button */}
{!result && (
<Pressable
accessibilityLabel="Process URL with intake pipeline"
{...buttonA11y('Process URL with intake pipeline', { disabled: busy || !incomingUrl.trim() })}
onPress={() => void handleProcess()}
disabled={busy || !incomingUrl.trim()}
style={[styles.button, (busy || !incomingUrl.trim()) ? styles.buttonDisabled : null]}
@ -206,7 +207,7 @@ export default function IntakeScreen() {
{busy ? (
<ActivityIndicator color={colors.textPrimary} size="small" />
) : (
<Text style={styles.buttonText}>Process</Text>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.buttonText}>Process</Text>
)}
</Pressable>
)}

View File

@ -4,6 +4,7 @@ import { ActivityIndicator, Pressable, ScrollView, Share, StyleSheet, Text, Text
import { useNotesStore, type NotesState } from '../../store/notes-store';
import { usePromptStore, type PromptState } from '../../store/prompt-store';
import { getReadingTime, suggestTags } from '../../api/note-prompts';
import { buttonA11y, textInputA11y, dynamicType } from '../../lib/accessibility';
import { colors } from '../../theme';
export default function NoteDetailScreen() {
@ -53,6 +54,7 @@ export default function NoteDetailScreen() {
{isEditing ? (
<>
<TextInput
{...textInputA11y('Note title')}
value={draftTitle}
onChangeText={setDraftTitle}
style={styles.input}
@ -60,6 +62,7 @@ export default function NoteDetailScreen() {
placeholderTextColor={colors.textTertiary}
/>
<TextInput
{...textInputA11y('Note body')}
value={draftBody}
onChangeText={setDraftBody}
style={[styles.input, styles.bodyInput]}
@ -70,7 +73,7 @@ export default function NoteDetailScreen() {
/>
<View style={styles.actionRow}>
<Pressable
accessibilityLabel="Cancel editing"
{...buttonA11y('Cancel editing')}
style={styles.secondaryButton}
onPress={() => {
setIsEditing(false);
@ -78,25 +81,25 @@ export default function NoteDetailScreen() {
setDraftBody(selectedNote?.body ?? '');
}}
>
<Text style={styles.secondaryButtonText}>Cancel</Text>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.secondaryButtonText}>Cancel</Text>
</Pressable>
<Pressable
accessibilityLabel="Save note changes"
{...buttonA11y('Save note changes')}
style={styles.primaryButton}
onPress={async () => {
await updateNote(noteId, draftTitle, draftBody);
setIsEditing(false);
}}
>
<Text style={styles.primaryButtonText}>Save</Text>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.primaryButtonText}>Save</Text>
</Pressable>
</View>
</>
) : !isLoading ? (
<>
<Text style={styles.body}>{selectedNote?.body ?? 'No note body is available yet.'}</Text>
<Pressable accessibilityLabel="Edit note" style={styles.primaryButton} onPress={() => setIsEditing(true)}>
<Text style={styles.primaryButtonText}>Edit note</Text>
<Pressable {...buttonA11y('Edit note')} style={styles.primaryButton} onPress={() => setIsEditing(true)}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.primaryButtonText}>Edit note</Text>
</Pressable>
</>
) : null}
@ -115,7 +118,7 @@ export default function NoteDetailScreen() {
<Text style={styles.sectionTitle}>Share</Text>
<View style={styles.actionRow}>
<Pressable
accessibilityLabel="Share note as text"
{...buttonA11y('Share note as text')}
style={styles.secondaryButton}
onPress={() => {
void Share.share({
@ -124,10 +127,10 @@ export default function NoteDetailScreen() {
});
}}
>
<Text style={styles.secondaryButtonText}>Share as text</Text>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.secondaryButtonText}>Share as text</Text>
</Pressable>
<Pressable
accessibilityLabel="Share deep link to note"
{...buttonA11y('Share deep link to note')}
style={styles.secondaryButton}
onPress={() => {
void Share.share({
@ -136,7 +139,7 @@ export default function NoteDetailScreen() {
});
}}
>
<Text style={styles.secondaryButtonText}>Share link</Text>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.secondaryButtonText}>Share link</Text>
</Pressable>
</View>
</View>
@ -145,7 +148,7 @@ export default function NoteDetailScreen() {
{/* Smart Actions */}
<View style={styles.card}>
<Pressable
accessibilityLabel="Toggle Smart Actions"
{...buttonA11y('Toggle Smart Actions', { expanded: showSmartActions })}
style={styles.actionRow}
onPress={() => {
setShowSmartActions(!showSmartActions);
@ -170,7 +173,7 @@ export default function NoteDetailScreen() {
)}
<Pressable
accessibilityLabel="Suggest tags"
{...buttonA11y('Suggest tags')}
style={styles.secondaryButton}
onPress={() => {
if (!selectedNote) return;
@ -179,7 +182,7 @@ export default function NoteDetailScreen() {
.catch(() => {});
}}
>
<Text style={styles.secondaryButtonText}>Suggest tags</Text>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.secondaryButtonText}>Suggest tags</Text>
</Pressable>
{suggestedTags.length > 0 && (
@ -195,7 +198,7 @@ export default function NoteDetailScreen() {
{templates.slice(0, 6).map((t) => (
<Pressable
key={t.id}
accessibilityLabel={`Run: ${t.name}`}
{...buttonA11y(`Run: ${t.name}`, { disabled: isRunning || !selectedNote })}
style={[styles.secondaryButton, isRunning ? { opacity: 0.5 } : null]}
disabled={isRunning || !selectedNote}
onPress={() => {
@ -207,7 +210,7 @@ export default function NoteDetailScreen() {
});
}}
>
<Text style={styles.secondaryButtonText}>{t.name}</Text>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.secondaryButtonText}>{t.name}</Text>
<Text style={[styles.body, { fontSize: 12 }]}>{t.description}</Text>
</Pressable>
))}
@ -228,8 +231,8 @@ export default function NoteDetailScreen() {
{lastResult.model} · {lastResult.usage?.totalTokens ?? 0} tokens
</Text>
)}
<Pressable accessibilityLabel="Dismiss result" style={styles.secondaryButton} onPress={clearResult}>
<Text style={styles.secondaryButtonText}>Dismiss</Text>
<Pressable {...buttonA11y('Dismiss result')} style={styles.secondaryButton} onPress={clearResult}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.secondaryButtonText}>Dismiss</Text>
</Pressable>
</View>
)}

View File

@ -1,6 +1,7 @@
import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { usePromptStore } from '../store/prompt-store';
import { buttonA11y, dynamicType } from '../lib/accessibility';
import { colors } from '../theme';
export default function PromptResultScreen() {
@ -12,8 +13,8 @@ export default function PromptResultScreen() {
return (
<View style={styles.container}>
<Text style={styles.empty}>No prompt result available.</Text>
<TouchableOpacity style={styles.btn} onPress={() => router.back()}>
<Text style={styles.btnText}>Go Back</Text>
<TouchableOpacity {...buttonA11y('Go back')} style={styles.btn} onPress={() => router.back()}>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.btnText}>Go Back</Text>
</TouchableOpacity>
</View>
);
@ -45,13 +46,14 @@ export default function PromptResultScreen() {
<View style={styles.actions}>
<TouchableOpacity
{...buttonA11y('Dismiss prompt result')}
style={styles.btn}
onPress={() => {
clearResult();
router.back();
}}
>
<Text style={styles.btnText}>Dismiss</Text>
<Text maxFontSizeMultiplier={dynamicType.control} style={styles.btnText}>Dismiss</Text>
</TouchableOpacity>
</View>
</ScrollView>

View File

@ -0,0 +1,39 @@
import type { AccessibilityState } from 'react-native';
export const dynamicType = {
control: 1.4,
body: 1.8,
heading: 1.5,
} as const;
type ButtonA11yOptions = {
disabled?: boolean;
expanded?: boolean;
selected?: boolean;
};
export function buttonA11y(label: string, options: ButtonA11yOptions = {}) {
const accessibilityState: AccessibilityState = {};
if (options.disabled !== undefined) {
accessibilityState.disabled = options.disabled;
}
if (options.expanded !== undefined) {
accessibilityState.expanded = options.expanded;
}
if (options.selected !== undefined) {
accessibilityState.selected = options.selected;
}
return {
accessibilityLabel: label,
accessibilityRole: 'button' as const,
...(Object.keys(accessibilityState).length > 0 ? { accessibilityState } : {}),
};
}
export function textInputA11y(label: string) {
return {
accessibilityLabel: label,
};
}