diff --git a/docs/STYLE_OPTIMIZATION.md b/docs/STYLE_OPTIMIZATION.md new file mode 100644 index 0000000..75eefe5 --- /dev/null +++ b/docs/STYLE_OPTIMIZATION.md @@ -0,0 +1,99 @@ +# Styling Optimization & Atomic CSS Guide + +This document describes the CSS-in-JS style audit, NativeWind atomic CSS verification, and styling optimization results implemented for teachLink Mobile. + +## 1. Style Audit Overview + +Our audit of the teachLink Mobile codebase identified a hybrid styling architecture: +- **Newer layouts and navigation components** utilized utility-first Tailwind classes via **NativeWind**. +- **Core interactive widgets and form elements** heavily relied on traditional React Native `StyleSheet.create` declarations and inline style objects. + +### Performance Impact of Mixed Styles +Traditional React Native `StyleSheet.create` rules are resolved dynamically at runtime and compiled into individual JavaScript style objects. Having dozens of components declaring near-identical properties (e.g., `flexDirection: 'row'`, `alignItems: 'center'`, padding, margins, colors) results in: +1. **Bloated JS Bundle**: Repetitive JavaScript objects are duplicated throughout the compiled bundle. +2. **Dynamic Style Overhead**: The React Native bridge must process and transfer duplicate style dictionaries at runtime. +3. **Poorer Cache-ability**: Styles cannot be effectively cached as individual, atomic instructions. + +--- + +## 2. NativeWind Atomic Styling Verification + +NativeWind v4 implements an **atomic CSS** compiler. It parses tailwind classes (e.g., `flex-row`, `items-center`, `bg-white`) inside JSX `className` properties at compile-time and maps them to a single, shared sheet of atomic React Native style definitions. + +### Advantages of the Atomic Approach +- **De-duplication**: The style `items-center` is defined exactly once in the NativeWind global registry and shared by all components. +- **Smaller Payload**: Instead of bundling massive stylesheet objects, components only bundle a string of class names (e.g., `"flex-row items-center px-4"`). +- **Fast Application**: Resolving pre-cached atomic styles is significantly faster than parsing and applying large nested style dictionaries. + +--- + +## 3. Style Refactoring Strategy + +We systematically refactored core components from `StyleSheet.create` to utility classes. We established a strict separation of concerns for styling: + +1. **Static Styles (Tailwind Classes)**: All layout, alignment, structure, flexbox, border widths, static background colors, and margins are defined via `className`. +2. **Dynamic Styles (Inline style)**: Runtime values computed dynamically (e.g., layout values scaled with `useDynamicFontSize`, theme-dependent colors, animated translations/opacities) are kept as minimal inline `style` objects. + +### Example Refactoring (`AccessibleButton.tsx`) +**Before (CSS-in-JS):** +```tsx +const AccessibleButton = ({ style, children }) => ( + + {children} + +); + +const styles = StyleSheet.create({ + base: { + minWidth: 44, + minHeight: 44, + justifyContent: 'center', + alignItems: 'center', + }, +}); +``` + +**After (Atomic NativeWind):** +```tsx +const AccessibleButton = ({ className, style, children }) => ( + + {children} + +); +``` + +--- + +## 4. Optimization Metrics & Results + +To measure the impact, we analyzed the codebase before and after our refactoring using a style-auditing script. + +| Metric | Before Optimization | After Optimization | Change | +| :--- | :---: | :---: | :---: | +| **StyleSheet.create() Calls** | 54 | 47 | **-7 (-13.0%)** | +| **Estimated CSS-in-JS Style Rules** | 3,769 | 3,488 | **-281 (-7.5%)** | +| **NativeWind className Attributes** | 157 | 234 | **+77 (+49.0%)** | + +### Optimized Components +The following heavy components were migrated completely away from local stylesheets: +1. `AccessibleButton.tsx` (Touch target sizing, centering) +2. `MobileFormInput.tsx` (Labels, icons, inputs, errors, focused/dark-mode colors) +3. `PrimaryButton.tsx` (Sizes, solid/outline/gradient layouts, text weight) +4. `PullToRefresh.tsx` (Containers, animated refresh indicators, accessibility fallbacks) +5. `VoiceSearch.tsx` (Compact/full mic triggers, action buttons, transcript bars) +6. `SearchResultCard.tsx` (List card layout, icon wraps, text sizing, metadata tags) +7. `VideoControls.tsx` (Seek bars, progress overlays, control bars, playback speed menus) + +--- + +## 5. Developer Best Practices + +To maintain styling consistency and performance, developers should adhere to the following guidelines: + +- **Prefer Tailwind Classes**: Always check if a design style can be represented by a Tailwind utility class before resorting to a stylesheet or inline style. +- **Support `className` in Shared Wrappers**: When writing reusable wrappers or atomic components, expose a `className` prop to allow consumers to pass custom utility classes. +- **Isolate Dynamic Styles**: Keep inline style values strictly limited to dynamic runtime parameters (e.g., animation outputs, scaled fonts, colors loaded from backend). +- **Consult Design Tokens**: Refer to `tailwind.config.js` for custom color palettes, font weights, and spacing scales. diff --git a/scripts/measure_styles.js b/scripts/measure_styles.js new file mode 100644 index 0000000..5489d62 --- /dev/null +++ b/scripts/measure_styles.js @@ -0,0 +1,72 @@ +const fs = require('fs'); +const path = require('path'); + +const srcDir = path.join(__dirname, '..', 'src'); +const appDir = path.join(__dirname, '..', 'app'); + +function walkDir(dir, filter) { + let results = []; + const list = fs.readdirSync(dir); + list.forEach(file => { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + if (stat && stat.isDirectory()) { + results = results.concat(walkDir(filePath, filter)); + } else if (filter(filePath)) { + results.push(filePath); + } + }); + return results; +} + +const files = [ + ...walkDir(srcDir, f => /\.(tsx|ts|jsx|js)$/.test(f)), + ...walkDir(appDir, f => /\.(tsx|ts|jsx|js)$/.test(f)) +]; + +let totalStyleSheetCalls = 0; +let totalStyleRules = 0; +let totalClassNames = 0; +let stylesheetDetails = []; + +files.forEach(file => { + const content = fs.readFileSync(file, 'utf8'); + const relPath = path.relative(path.join(__dirname, '..'), file); + + // Match StyleSheet.create(...) + if (content.includes('StyleSheet.create')) { + totalStyleSheetCalls++; + // Simple estimation of number of rules inside StyleSheet.create + const ssMatch = content.match(/StyleSheet\.create\(\s*\{([\s\S]*?)\}\s*\)/g); + if (ssMatch) { + ssMatch.forEach(match => { + // Count top-level properties inside the object + const innerContent = match.replace(/StyleSheet\.create\(\s*\{/, '').replace(/\}\s*\)/, ''); + // Match properties like name: { ... } or "name": { ... } + // We can approximate by counting blocks of braces or keys + const keys = innerContent.match(/^\s*[\w"']+\s*:/gm) || []; + totalStyleRules += keys.length; + stylesheetDetails.push({ file: relPath, rules: keys.length }); + }); + } + } + + // Match className="..." or className={`...`} + const classNameMatches = content.match(/className\s*=\s*(?:['"]([^'"]+)['"]|\{[\s\S]*?\})/g) || []; + totalClassNames += classNameMatches.length; +}); + +console.log('\nšŸ“Š STYLING AUDIT REPORT šŸ“Š'); +console.log('============================'); +console.log(`Total Files Audited: ${files.length}`); +console.log(`StyleSheet.create() Calls: ${totalStyleSheetCalls}`); +console.log(`Estimated CSS-in-JS Style Rules: ${totalStyleRules}`); +console.log(`NativeWind className Attributes: ${totalClassNames}`); +console.log('\nTop CSS-in-JS Stylesheets (by number of rules):'); +stylesheetDetails + .sort((a, b) => b.rules - a.rules) + .slice(0, 15) + .forEach(item => { + console.log(`- ${item.file}: ${item.rules} rules`); + }); +console.log('============================\n'); diff --git a/src/components/common/PrimaryButton.tsx b/src/components/common/PrimaryButton.tsx index 297d2d5..b9450a4 100644 --- a/src/components/common/PrimaryButton.tsx +++ b/src/components/common/PrimaryButton.tsx @@ -6,7 +6,6 @@ import { View, ViewStyle, TextStyle, - StyleSheet, } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { useDynamicFontSize } from '../../hooks'; @@ -93,15 +92,12 @@ export default function PrimaryButton({ colors={['#20afe7', '#2c8aec', '#586ce9']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} - style={[ - styles.button, - styles.gradientButton, - { - paddingHorizontal: config.paddingHorizontal, - paddingVertical: config.paddingVertical, - borderRadius: config.borderRadius, - }, - ]} + className="flex-row items-center justify-center gap-2 shadow-sm shadow-[#20afe7]/30 elevation-4" + style={{ + paddingHorizontal: config.paddingHorizontal, + paddingVertical: config.paddingVertical, + borderRadius: config.borderRadius, + }} > {loading ? ( @@ -110,8 +106,8 @@ export default function PrimaryButton({ {icon} {title} @@ -209,21 +209,3 @@ export default function PrimaryButton({ ); } -const styles = StyleSheet.create({ - button: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - }, - gradientButton: { - shadowColor: '#20afe7', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 8, - elevation: 4, - }, - buttonText: { - fontWeight: '600', - }, -}); diff --git a/src/components/mobile/AccessibleButton.tsx b/src/components/mobile/AccessibleButton.tsx index c113908..21df836 100644 --- a/src/components/mobile/AccessibleButton.tsx +++ b/src/components/mobile/AccessibleButton.tsx @@ -2,9 +2,8 @@ import React from 'react'; import { TouchableOpacity, TouchableOpacityProps, - StyleSheet, - ViewStyle, StyleProp, + ViewStyle, } from 'react-native'; import { getAccessibilityProps } from '../../utils/accessibility'; @@ -20,6 +19,8 @@ interface AccessibleButtonProps extends TouchableOpacityProps { role?: 'button' | 'link'; /** Optional custom styles for the button container */ containerStyle?: StyleProp; + /** Optional NativeWind className */ + className?: string; } /** @@ -35,6 +36,7 @@ export const AccessibleButton: React.FC = ({ containerStyle, disabled, activeOpacity = 0.7, + className, ...rest }: AccessibleButtonProps) => { const accessibilityProps = getAccessibilityProps(label, role as 'button' | 'link', hint, { @@ -47,18 +49,11 @@ export const AccessibleButton: React.FC = ({ {...accessibilityProps} disabled={disabled} activeOpacity={activeOpacity} - style={[styles.base, containerStyle, style]} + className={`min-touch-target justify-center items-center ${className || ''}`} + style={[containerStyle, style]} > {children} ); }; -const styles = StyleSheet.create({ - base: { - minWidth: 44, - minHeight: 44, - justifyContent: 'center', - alignItems: 'center', - }, -}); diff --git a/src/components/mobile/MobileFormInput.tsx b/src/components/mobile/MobileFormInput.tsx index e34cfb5..d33a8ec 100644 --- a/src/components/mobile/MobileFormInput.tsx +++ b/src/components/mobile/MobileFormInput.tsx @@ -1,6 +1,6 @@ import { Eye, EyeOff, AlertCircle } from 'lucide-react-native'; import React, { useCallback, useEffect, useState } from 'react'; -import { View, TextInput, TextInputProps, TouchableOpacity, StyleSheet } from 'react-native'; +import { View, TextInput, TextInputProps, TouchableOpacity } from 'react-native'; import { useDynamicFontSize } from '../../hooks'; import { @@ -105,44 +105,46 @@ export const MobileFormInput: React.FC = ({ const labelColor = error ? '#ef4444' : isDark ? '#94a3b8' : '#64748b'; return ( - - - + + + {label} - {required && *} + {required && ( + + {' '} + * + + )} {hint && !error && ( - + {hint} )} - {leftIcon && {leftIcon}} + {leftIcon && {leftIcon}} = ({ /> {isPassword && ( - + {showPassword ? ( ) : ( @@ -169,30 +171,24 @@ export const MobileFormInput: React.FC = ({ {suggestion && !error && ( Use saved: {suggestion} @@ -201,86 +197,14 @@ export const MobileFormInput: React.FC = ({ )} {error && ( - + - {error} + + {error} + )} ); }; -const styles = StyleSheet.create({ - container: { - marginBottom: 16, - width: '100%', - }, - labelRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 6, - }, - label: { - fontSize: 14, - fontWeight: '600', - letterSpacing: 0.1, - }, - required: { - color: '#ef4444', - }, - hint: { - fontSize: 12, - }, - inputWrapper: { - flexDirection: 'row', - alignItems: 'center', - borderWidth: 1.5, - borderRadius: 12, - overflow: 'hidden', - }, - leftIconWrapper: { - paddingHorizontal: 14, - justifyContent: 'center', - alignItems: 'center', - }, - input: { - flex: 1, - fontSize: 15, - paddingVertical: 14, - paddingRight: 16, - }, - rightIcon: { - paddingHorizontal: 14, - }, - errorRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - marginTop: 4, - }, - errorText: { - fontSize: 12, - color: '#ef4444', - flex: 1, - }, - suggestionRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - marginTop: 6, - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 10, - borderWidth: 1, - }, - suggestionLabel: { - fontSize: 12, - fontWeight: '500', - }, - suggestionValue: { - flex: 1, - fontSize: 12, - fontWeight: '600', - }, -}); diff --git a/src/components/mobile/PullToRefresh.tsx b/src/components/mobile/PullToRefresh.tsx index 0cf9469..8acb0f3 100644 --- a/src/components/mobile/PullToRefresh.tsx +++ b/src/components/mobile/PullToRefresh.tsx @@ -6,7 +6,6 @@ import { Easing, Pressable, StyleProp, - StyleSheet, View, ViewStyle, } from 'react-native'; @@ -211,9 +210,9 @@ export function PullToRefresh(props: PullToRefreshProps) { }); return ( - + {showA11yFallbackButton && screenReaderEnabled ? ( - + - Refresh + Refresh ) : null} ); } - -const styles = StyleSheet.create({ - container: { - flex: 1, - overflow: 'hidden', - }, - indicator: { - position: 'absolute', - top: -44, - left: 0, - right: 0, - height: 44, - alignItems: 'center', - justifyContent: 'center', - }, - a11yRow: { - paddingHorizontal: 12, - paddingTop: 8, - paddingBottom: 4, - }, - a11yButton: { - alignSelf: 'flex-start', - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 10, - backgroundColor: '#e5e7eb', - }, - a11yButtonText: { - fontSize: 14, - fontWeight: '600', - color: '#111827', - }, -}); diff --git a/src/components/mobile/SearchResultCard.tsx b/src/components/mobile/SearchResultCard.tsx index 829f200..1348ce2 100644 --- a/src/components/mobile/SearchResultCard.tsx +++ b/src/components/mobile/SearchResultCard.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { View, Text, TouchableOpacity } from 'react-native'; import { BookOpen, Clock } from 'lucide-react-native'; export interface SearchResultItem { @@ -31,31 +31,31 @@ export const SearchResultCard = React.memo(function SearchResultCard({ return ( - + - - + + {item.title} {(item.description || item.subtitle) && ( - + {item.description || item.subtitle} )} - - {metaText ? {metaText} : null} + + {metaText ? {metaText} : null} {item.duration != null && item.duration > 0 && ( - + - {item.duration} min + {item.duration} min )} @@ -72,66 +72,3 @@ export const SearchResultCard = React.memo(function SearchResultCard({ && prev.item.level === next.item.level; }); -const styles = StyleSheet.create({ - card: { - flexDirection: 'row', - alignItems: 'flex-start', - backgroundColor: '#fff', - borderRadius: 12, - padding: 14, - marginHorizontal: 16, - marginBottom: 10, - borderWidth: 1, - borderColor: '#E5E7EB', - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.05, - shadowRadius: 2, - elevation: 1, - }, - iconWrap: { - width: 44, - height: 44, - borderRadius: 10, - backgroundColor: '#E0F2FE', - alignItems: 'center', - justifyContent: 'center', - marginRight: 12, - }, - body: { - flex: 1, - minWidth: 0, - }, - title: { - fontSize: 16, - fontWeight: '600', - color: '#111827', - marginBottom: 4, - }, - description: { - fontSize: 13, - color: '#6B7280', - lineHeight: 18, - marginBottom: 6, - }, - metaRow: { - flexDirection: 'row', - alignItems: 'center', - flexWrap: 'wrap', - gap: 8, - }, - meta: { - fontSize: 12, - color: '#6B7280', - fontWeight: '500', - }, - durationWrap: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - }, - duration: { - fontSize: 12, - color: '#6B7280', - }, -}); diff --git a/src/components/mobile/VideoControls.tsx b/src/components/mobile/VideoControls.tsx index 577fecc..5dc2953 100644 --- a/src/components/mobile/VideoControls.tsx +++ b/src/components/mobile/VideoControls.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Animated, Pressable, StyleSheet, Text, View } from 'react-native'; +import { Animated, Pressable, Text, View } from 'react-native'; import type { QualityOption } from '../../services/videoQuality'; type VideoControlsProps = { @@ -156,55 +156,59 @@ const VideoControls = ({ }, [onSelectQuality]); return ( - - - + + + {isPiPSupported ? ( - {isPiPActive ? 'PiP On' : 'PiP'} + {isPiPActive ? 'PiP On' : 'PiP'} ) : null} - {isFullscreen ? 'Exit' : 'Full'} + {isFullscreen ? 'Exit' : 'Full'} - + - {isPlaying ? 'Pause' : 'Play'} + {isPlaying ? 'Pause' : 'Play'} - + {previewPositionMillis != null ? ( - - + + {formatTime(displayPosition)} / {formatTime(durationMillis)} ) : null} - - {formatTime(displayPosition)} - {formatTime(durationMillis)} + + {formatTime(displayPosition)} + {formatTime(durationMillis)} true} onResponderGrant={handleSeekGrant} @@ -212,25 +216,33 @@ const VideoControls = ({ onResponderRelease={handleSeekRelease} onResponderTerminate={handleSeekTerminate} > - - - + + + - + - {formatRate(playbackRate)} + {formatRate(playbackRate)} - {qualityLabel} + {qualityLabel} {isPiPSupported ? ( - {isPiPActive ? 'PiP On' : 'PiP'} + {isPiPActive ? 'PiP On' : 'PiP'} ) : null} - {isFullscreen ? 'Exit' : 'Full'} + {isFullscreen ? 'Exit' : 'Full'} {(isSpeedMenuOpen || isQualityMenuOpen) && ( - + {isSpeedMenuOpen ? ( - + {rateOptions.map(rate => ( handleSelectRate(rate)} - style={styles.menuItem} + className="px-3 py-2" > {formatRate(rate)} @@ -277,19 +288,18 @@ const VideoControls = ({ ) : null} {isQualityMenuOpen ? ( - + {qualityOptions.map(option => ( handleSelectQualityOption(option.id)} - style={styles.menuItem} + className="px-3 py-2" > {option.label} @@ -301,8 +311,8 @@ const VideoControls = ({ )} {(isLoading || isBuffering || isSwitchingQuality) && ( - - + + {isSwitchingQuality ? 'Switching quality' : isBuffering ? 'Buffering' : 'Loading'} @@ -338,131 +348,5 @@ function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } -const styles = StyleSheet.create({ - overlay: { - ...StyleSheet.absoluteFillObject, - justifyContent: 'space-between', - backgroundColor: 'rgba(0, 0, 0, 0.35)', - }, - topRow: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 12, - paddingTop: 10, - }, - topSpacer: { - flex: 1, - }, - centerRow: { - alignItems: 'center', - justifyContent: 'center', - }, - bottomRow: { - paddingHorizontal: 12, - paddingBottom: 12, - }, - playButton: { - backgroundColor: 'rgba(0, 0, 0, 0.55)', - paddingHorizontal: 18, - paddingVertical: 10, - borderRadius: 22, - }, - playText: { - color: '#fff', - fontSize: 16, - fontWeight: '600', - }, - timeRow: { - flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: 6, - }, - timeText: { - color: '#f1f1f1', - fontSize: 12, - }, - seekBar: { - height: 22, - borderRadius: 10, - backgroundColor: 'rgba(255, 255, 255, 0.15)', - justifyContent: 'center', - marginBottom: 10, - overflow: 'hidden', - }, - seekBuffered: { - position: 'absolute', - left: 0, - top: 0, - bottom: 0, - backgroundColor: 'rgba(255, 255, 255, 0.3)', - }, - seekProgress: { - position: 'absolute', - left: 0, - top: 0, - bottom: 0, - backgroundColor: '#fff', - }, - seekThumb: { - position: 'absolute', - width: SEEK_THUMB_SIZE, - height: SEEK_THUMB_SIZE, - borderRadius: SEEK_THUMB_SIZE / 2, - backgroundColor: '#fff', - }, - controlsRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - controlButton: { - paddingHorizontal: 10, - paddingVertical: 6, - }, - controlText: { - color: '#fff', - fontSize: 12, - fontWeight: '600', - }, - menuBackdrop: { - marginTop: 8, - }, - menu: { - backgroundColor: 'rgba(0, 0, 0, 0.85)', - borderRadius: 8, - paddingVertical: 6, - }, - menuItem: { - paddingHorizontal: 12, - paddingVertical: 8, - }, - menuText: { - color: '#dcdcdc', - fontSize: 13, - }, - menuTextActive: { - color: '#fff', - fontWeight: '700', - }, - previewBubble: { - alignSelf: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.65)', - paddingHorizontal: 10, - paddingVertical: 4, - borderRadius: 12, - marginBottom: 8, - }, - previewText: { - color: '#fff', - fontSize: 12, - }, - statusRow: { - marginTop: 6, - }, - statusText: { - color: '#fff', - fontSize: 11, - }, -}); - export default VideoControls; + diff --git a/src/components/mobile/VoiceSearch.tsx b/src/components/mobile/VoiceSearch.tsx index af5ff0f..8d51206 100644 --- a/src/components/mobile/VoiceSearch.tsx +++ b/src/components/mobile/VoiceSearch.tsx @@ -1,6 +1,6 @@ import { Mic, Square } from 'lucide-react-native'; import React, { useEffect } from 'react'; -import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native'; import * as hooks from '../../hooks'; @@ -74,7 +74,7 @@ export const VoiceSearch = ({ @@ -88,38 +88,42 @@ export const VoiceSearch = ({ } return ( - + {error ? ( - + {error} ) : null} {isListening ? ( <> - Stop + Stop ) : ( <> - Voice + + Voice + )} {isListening && ( - + - + {transcript || 'Listening...'} @@ -128,61 +132,3 @@ export const VoiceSearch = ({ ); }; -const styles = StyleSheet.create({ - wrapper: { - alignItems: 'center', - }, - button: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 6, - paddingVertical: 10, - paddingHorizontal: 16, - borderRadius: 12, - backgroundColor: '#F3F4F6', - minWidth: 88, - }, - buttonActive: { - backgroundColor: '#19c3e6', - }, - buttonDisabled: { - opacity: 0.5, - }, - buttonLabel: { - fontSize: 14, - fontWeight: '600', - color: '#111827', - }, - buttonLabelMuted: { - color: '#9CA3AF', - }, - error: { - fontSize: 11, - color: '#EF4444', - marginBottom: 4, - textAlign: 'center', - maxWidth: 140, - }, - listeningBar: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - marginTop: 8, - paddingHorizontal: 12, - paddingVertical: 6, - backgroundColor: '#E0F2FE', - borderRadius: 8, - maxWidth: '100%', - }, - listeningText: { - fontSize: 13, - color: '#0369A1', - flex: 1, - }, - micBtn: { - padding: 10, - justifyContent: 'center', - alignItems: 'center', - }, -});