Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions docs/STYLE_OPTIMIZATION.md
Original file line number Diff line number Diff line change
@@ -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 }) => (
<TouchableOpacity style={[styles.base, style]}>
{children}
</TouchableOpacity>
);

const styles = StyleSheet.create({
base: {
minWidth: 44,
minHeight: 44,
justifyContent: 'center',
alignItems: 'center',
},
});
```

**After (Atomic NativeWind):**
```tsx
const AccessibleButton = ({ className, style, children }) => (
<TouchableOpacity
className={`min-touch-target justify-center items-center ${className || ''}`}
style={style}
>
{children}
</TouchableOpacity>
);
```

---

## 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.
72 changes: 72 additions & 0 deletions scripts/measure_styles.js
Original file line number Diff line number Diff line change
@@ -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');
48 changes: 15 additions & 33 deletions src/components/common/PrimaryButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
View,
ViewStyle,
TextStyle,
StyleSheet,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { useDynamicFontSize } from '../../hooks';
Expand Down Expand Up @@ -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 ? (
<ActivityIndicator color="white" size="small" />
Expand All @@ -110,8 +106,8 @@ export default function PrimaryButton({
{icon}
<Text
allowFontScaling={false}
className="font-semibold"
style={[
styles.buttonText,
{ fontSize: config.fontSize, color: '#ffffff' },
textStyle,
]}
Expand All @@ -135,8 +131,8 @@ export default function PrimaryButton({
accessibilityLabel={buttonLabel}
accessibilityHint={accessibilityHint}
accessibilityState={{ disabled: isDisabled, busy: loading }}
className="flex-row items-center justify-center gap-2"
style={[
styles.button,
{
backgroundColor: '#19c3e6',
paddingHorizontal: config.paddingHorizontal,
Expand All @@ -154,8 +150,8 @@ export default function PrimaryButton({
{icon}
<Text
allowFontScaling={false}
className="font-semibold"
style={[
styles.buttonText,
{ fontSize: config.fontSize, color: '#ffffff' },
textStyle,
]}
Expand All @@ -178,8 +174,8 @@ export default function PrimaryButton({
accessibilityLabel={buttonLabel}
accessibilityHint={accessibilityHint}
accessibilityState={{ disabled: isDisabled, busy: loading }}
className="flex-row items-center justify-center gap-2"
style={[
styles.button,
{
borderWidth: 2,
borderColor: '#19c3e6',
Expand All @@ -199,7 +195,11 @@ export default function PrimaryButton({
{icon}
<Text
allowFontScaling={false}
style={[styles.buttonText, { fontSize: config.fontSize, color: '#19c3e6' }, textStyle]}
className="font-semibold"
style={[
{ fontSize: config.fontSize, color: '#19c3e6' },
textStyle,
]}
>
{title}
</Text>
Expand All @@ -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',
},
});
17 changes: 6 additions & 11 deletions src/components/mobile/AccessibleButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ import React from 'react';
import {
TouchableOpacity,
TouchableOpacityProps,
StyleSheet,
ViewStyle,
StyleProp,
ViewStyle,
} from 'react-native';
import { getAccessibilityProps } from '../../utils/accessibility';

Expand All @@ -20,6 +19,8 @@ interface AccessibleButtonProps extends TouchableOpacityProps {
role?: 'button' | 'link';
/** Optional custom styles for the button container */
containerStyle?: StyleProp<ViewStyle>;
/** Optional NativeWind className */
className?: string;
}

/**
Expand All @@ -35,6 +36,7 @@ export const AccessibleButton: React.FC<AccessibleButtonProps> = ({
containerStyle,
disabled,
activeOpacity = 0.7,
className,
...rest
}: AccessibleButtonProps) => {
const accessibilityProps = getAccessibilityProps(label, role as 'button' | 'link', hint, {
Expand All @@ -47,18 +49,11 @@ export const AccessibleButton: React.FC<AccessibleButtonProps> = ({
{...accessibilityProps}
disabled={disabled}
activeOpacity={activeOpacity}
style={[styles.base, containerStyle, style]}
className={`min-touch-target justify-center items-center ${className || ''}`}
style={[containerStyle, style]}
>
{children}
</TouchableOpacity>
);
};

const styles = StyleSheet.create({
base: {
minWidth: 44,
minHeight: 44,
justifyContent: 'center',
alignItems: 'center',
},
});
Loading
Loading