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
5 changes: 5 additions & 0 deletions .github/workflows/bundle-size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ jobs:
- name: Build web bundle with stats
run: npx expo export --platform web --output-dir ./dist --stats-output ./dist/stats.json

- name: Lighthouse CI
run: npx lhci autorun --config=./lighthouserc.json
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

- name: Upload bundle stats artifact
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ jobs:
${{ runner.os }}-pip-

- name: Install subsetting dependencies
run: pip install fonttools
run: pip install -r requirements.txt

- name: Setup Node.js
uses: actions/setup-node@v4
Expand All @@ -89,7 +89,7 @@ jobs:
path: |
assets/fonts/*.ttf
!assets/fonts/original/*.ttf
key: ${{ runner.os }}-fonts-${{ hashFiles('assets/fonts/original/*.ttf', 'scripts/subset-fonts.py') }}
key: ${{ runner.os }}-fonts-${{ hashFiles('assets/fonts/original/*.ttf', 'scripts/subset-fonts.py', 'requirements.txt') }}
restore-keys: |
${{ runner.os }}-fonts-

Expand Down
183 changes: 183 additions & 0 deletions docs/FONT_PIPELINE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Font Pipeline

TeachLink uses a three-stage font pipeline: **subsetting**, **analysis**, and **runtime loading**.

---

## Overview

```
assets/fonts/original/*.ttf ← Source fonts (checked into repo)
│
▼
scripts/subset-fonts.py ← Stage 1: Character-level subsetting
│
▼
assets/fonts/*.ttf ← Subsetted fonts (output, loaded at runtime)
│
├──► src/services/fontService.ts ← Stage 3: Runtime loading
│
scripts/analyze-fonts.js ← Stage 2: Character usage analysis (supplementary)
```

---

## Stage 1: Subsetting (`scripts/subset-fonts.py`)

This is the **canonical** font subsetting script used by CI.

### What it does

1. Scans all `.ts`, `.tsx`, `.js`, `.jsx`, `.json`, `.html`, and `.css` files in `app/` and `src/`
2. Collects every unique character found in the source code
3. Adds a baseline of printable ASCII characters (space–tilde)
4. Writes the character set to a temporary file
5. Runs `fontTools.subset` (via `pyftsubset`) to create a minimal `.ttf` containing only the used characters
6. Outputs subsetted fonts to `assets/fonts/`

### Usage

```bash
# npm scripts (both are equivalent)
npm run subset-fonts
npm run fonts:subset

# Direct
python scripts/subset-fonts.py
```

### Requirements

- Python 3.10+
- `fonttools==4.62.0` (pinned in `requirements.txt`)

### CI integration

In `.github/workflows/ci.yml`:

1. Python dependencies are installed from `requirements.txt`
2. Subsetted fonts are cached with a key derived from:
- `assets/fonts/original/*.ttf` (source font content)
- `scripts/subset-fonts.py` (subsetting logic)
- `requirements.txt` (tool version)
3. Subsetting runs only on cache miss

---

## Stage 2: Analysis (`scripts/analyze-fonts.js`)

A **supplementary** tool for understanding character usage across the project. Not part of the CI pipeline.

### What it does

1. Scans all `.ts`, `.tsx`, `.js`, `.jsx` files in `src/`
2. Extracts string literals and JSX text content
3. Categorises characters by script (Latin, Cyrillic, Greek, etc.)
4. Generates a report in `assets/fonts/analysis/font-analysis.json`

### Usage

```bash
npm run fonts:analyze
```

### Output

The report includes:
- Total and unique character counts
- Character frequency (top 20 most-used characters)
- Recommended character subsets for subsetting optimisation

---

## Stage 3: Runtime Loading (`src/services/fontService.ts`)

The `FontService` class manages font loading at runtime using `expo-font`.

### Font Categories

Defined in `src/services/fontService.ts`:

#### `CRITICAL_FONTS` — loaded before splash screen dismiss

| Font | Weight | Purpose |
|---|---|---|
| Inter-Regular | 400 | Body text, UI labels |
| Inter-Medium | 500 | Emphasised body text |
| Inter-Bold | 700 | Headings, primary buttons |

#### `SECONDARY_FONTS` — loaded lazily after initial render

| Font | Weight | Purpose |
|---|---|---|
| Inter-SemiBold | 600 | Subheadings, secondary buttons |

### Loading Strategy

1. `FontService.preloadCriticalFonts()` is called during app bootstrap
2. Critical fonts block splash screen dismissal
3. Secondary fonts load on demand after the first screen renders
4. All fonts are loaded via `expo-font`'s `Font.loadAsync()`

### Font Configuration

Font families, weights, and typography presets are defined in `src/config/fonts.ts`:

```typescript
// Font families
FONT_FAMILIES.Inter.weights = {
'400': 'Inter-Regular',
'500': 'Inter-Medium',
'600': 'Inter-SemiBold',
'700': 'Inter-Bold',
};

// Character subsets available for subsetting
CHARACTER_SETS = {
latin, latinExtended, cyrillic, greek, symbols, numbers, punctuation
};
```

---

## Relationship Between CRITICAL_FONTS and Subset Output

`CRITICAL_FONTS` references the subsetted `.ttf` files via `require()`:

```typescript
export const FONTS = {
'Inter-Regular': require('../../assets/fonts/Inter-Regular.ttf'),
'Inter-Bold': require('../../assets/fonts/Inter-Bold.ttf'),
'Inter-Medium': require('../../assets/fonts/Inter-Medium.ttf'),
'Inter-SemiBold': require('../../assets/fonts/Inter-SemiBold.ttf'),
} as const;
```

The subsetting script (`subset-fonts.py`) produces files at the same paths (`assets/fonts/Inter-*.ttf`), so the `require()` calls always resolve to the subsetted versions.

---

## Troubleshooting

### Missing characters in the rendered app

If a character doesn't render, it was likely missed by the subsetting scan:

1. Run `npm run fonts:analyze` to check which characters the project uses
2. Ensure the character appears in a `.ts`, `.tsx`, `.js`, `.jsx`, `.json`, `.html`, or `.css` file under `app/` or `src/`
3. Re-run `npm run subset-fonts` to regenerate the subset

### Font cache not updating in CI

If CI uses stale subsetted fonts:

1. Check if `requirements.txt` version changed (this invalidates the cache)
2. Check if source font files in `assets/fonts/original/` changed
3. Manually clear the cache: `gh cache delete $CACHE_KEY`

### Adding a new font weight

1. Add the `.ttf` file to `assets/fonts/original/`
2. Re-run `npm run subset-fonts`
3. Add the font to `FONTS`, `CRITICAL_FONTS` or `SECONDARY_FONTS` in `src/services/fontService.ts`
4. Add the weight mapping in `src/config/fonts.ts`
4 changes: 2 additions & 2 deletions docs/PERFORMANCE_MONITORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ render timing, Core Web Vitals, crash reporting, and production analytics.
AnalyticsProvider (root)
├── mobileAnalyticsService – event ingestion & sampling
├── crashReportingService – global JS / promise error handlers
└── webVitalsService – LCP, FID, CLS, FCP, TTFB (web target)
└── webVitalsService – LCP, INP, CLS, FCP, TTFB (web target)

useReactProfiler (hook)
└── Profiler (React built-in)
Expand Down Expand Up @@ -47,7 +47,7 @@ surfaces that use it contribute to the same monitoring stream.
| Metric | Good | Needs Improvement | Poor | Description |
|---|---|---|---|---|
| LCP | ≤ 2500 ms | ≤ 4000 ms | > 4000 ms | Largest Contentful Paint |
| FID | ≤ 100 ms | ≤ 300 ms | > 300 ms | First Input Delay |
| INP | ≤ 200 ms | ≤ 500 ms | > 500 ms | Interaction to Next Paint |
| CLS | ≤ 0.1 | ≤ 0.25 | > 0.25 | Cumulative Layout Shift |
| FCP | ≤ 1800 ms | ≤ 3000 ms | > 3000 ms | First Contentful Paint |
| TTFB | ≤ 800 ms | ≤ 1800 ms | > 1800 ms | Time to First Byte |
Expand Down
101 changes: 77 additions & 24 deletions docs/PERFORMANCE_THRESHOLDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,66 @@ Any metric that worsens by **more than 5%** vs the stored baseline will fail the

---

## Thresholds

| Metric | Budget | Regression Gate |
| --------------------- | -------- | ------------------------ |
| Bundle size (Android) | 2.5 MB | >5% increase vs baseline |
| Bundle size (iOS) | 2.5 MB | >5% increase vs baseline |
| Bundle size (total) | 5 MB | >5% increase vs baseline |
| Startup time p95 | 2 000 ms | >5% increase vs baseline |
| API latency p95 | 1 000 ms | >5% increase vs baseline |
| API latency p99 | 2 000 ms | >5% increase vs baseline |
| API error rate | < 1% | absolute (k6 threshold) |
## Absolute Budgets

Absolute budgets are defined in [`performance-budget.json`](../performance-budget.json).
The regression baseline is stored in [`performance-baseline.json`](../performance-baseline.json).
Every gated metric must have a documented budget below.

### Bundle Size

| Metric | Budget | Rationale |
|---|---|---|
| Android bundle | 2.5 MB | Keeps install size under Play Store's "large app" warning threshold (50 MB download). Covers JS + assets for a mid-complexity educational app. |
| iOS bundle | 2.5 MB | Mirrors Android for parity. iOS has higher memory overhead, so the bundle should be conservative. |
| Total (all platforms) | 5 MB | Aggregate cap across Android + iOS ensures neither platform silently grows while the other stays flat. |

### Startup Time

| Metric | Budget | Rationale |
|---|---|---|
| p50 | 1 000 ms | Median user should see the splash screen dismissed within 1 s. |
| p95 | 2 000 ms | Worst-case 95th-percentile budget based on Google's "Time to Interactive" guidance for mobile web. |

### Frame Rate

| Metric | Budget | Rationale |
|---|---|---|
| Min FPS | ≥ 55 fps | Targeting smooth 60 fps with a small margin. Drops below 55 fps cause visible jank. |
| Max dropped frames / session | ≤ 5 | More than 5 dropped frames per session is perceptible and correlates with poor App Store reviews. |

### API Latency

| Metric | Budget | Rationale |
|---|---|---|
| p50 | 300 ms | Median response should be near-instant; users perceive > 300 ms as sluggish. |
| p95 | 1 000 ms | The 95th percentile covers regional CDN variations and cold-start penalties. |
| p99 | 2 000 ms | Upper bound — the slowest 1 % of requests should still complete within 2 s. |

### Memory

| Metric | Budget | Rationale |
|---|---|---|
| Max heap | 128 MB | Exceeding 128 MB heap triggers GC pauses > 50 ms on mid-range Android devices. |
| Max native | 80 MB | Native memory (images, fonts, video) should stay under 80 MB to avoid OOM kills on low-RAM devices. |

### Lighthouse

| Metric | Budget | Rationale |
|---|---|---|
| Performance score | ≥ 50 | Minimum passing score for CI. Higher scores require further optimisation work. |
| FCP | ≤ 3 000 ms | First Contentful Paint — users see initial content within 3 s. |
| LCP | ≤ 4 000 ms | Largest Contentful Paint — main content is visible within 4 s. |
| CLS | ≤ 0.25 | Cumulative Layout Shift — below the "needs improvement" threshold. |
| TBT | ≤ 3 000 ms | Total Blocking Time — main thread is responsive. |
| Speed Index | ≤ 4 000 ms | Visual completeness within 4 s. |
| TTI | ≤ 5 000 ms | Time to Interactive — app is fully interactive within 5 s. |

---

## CI Workflow
## Regression Gate

The workflow (`.github/workflows/performance-regression.yml`) runs four jobs:
The CI workflow (`.github/workflows/performance-regression.yml`) runs four jobs and a
consolidated regression gate:

### 1. `bundle-size`

Expand All @@ -43,28 +83,41 @@ The workflow (`.github/workflows/performance-regression.yml`) runs four jobs:
### 3. `api-latency`

- Installs [k6](https://k6.io) and runs `scripts/k6-api-benchmark.js`
- 3-stage load: ramp 0→5 VUs (10s), hold 10 VUs (20s), ramp down (10s)
- k6 built-in thresholds: `p(95)<1000ms`, `p(99)<2000ms`, `error_rate<1%`
- 3-stage load: ramp 0→5 VUs (10 s), hold 10 VUs (20 s), ramp down (10 s)
- k6 built-in thresholds: `p(95)<1000 ms`, `p(99)<2000 ms`, `error_rate<1%`
- Compares p95 against the cached baseline
- Fails if p95 API latency grew by >5%

### 4. `regression-gate`

- Downloads all three reports
- Runs `scripts/checkPerfRegression.js` for a consolidated view
- **Missing metrics fail the check** — a null baseline or null current value
is treated as a regression, not silently skipped
- Posts a summary table as a PR comment (updates existing comment on re-runs)
- Fails the gate if any metric regressed by >5%
- Fails the gate if any metric regressed by >5% or is missing

---

## Baseline Management

Baselines are stored in two places:

| Store | Purpose |
| --------------------------------------------- | ---------------------------------------------------------- |
| `performance-baseline.json` (committed) | Human-readable reference; used by `checkPerfRegression.js` |
| GitHub Actions cache (`perf-*-baseline-main`) | Per-job comparison; updated on every `main` push |
| Store | Purpose |
|---|---|
| `performance-baseline.json` (committed) | Human-readable reference; used by `checkPerfRegression.js` |
| GitHub Actions cache (`perf-*-baseline-main`) | Per-job comparison; updated on every `main` push |

### Baseline regeneration guard

`npm run perf:update-baseline` will **refuse to run** if the current baseline
contains fabricated data (`"fabricated": true`). The audit analyzers that use
`Math.random()` to generate fake metrics must be replaced with real measurements
before the baseline chain is trusted:

- `src/audit/analyzers/MemoryAnalyzer.ts` — fabricated `avgRenderTime`, `renders`, `droppedFrames`
- `src/audit/analyzers/RuntimeAnalyzer.ts` — fabricated event loop lag
- `src/audit/analyzers/NetworkAnalyzer.ts` — fabricated `avgLatency`, `errorRate`, `requests`, `dataSize`

### Updating the baseline

Expand Down Expand Up @@ -95,13 +148,13 @@ The GitHub Actions cache baselines update automatically on every successful `mai

```bash
# Startup time benchmark (10 iterations)
npm run perf:startup
node scripts/measureStartupTime.js

# API latency (requires k6 installed: https://k6.io/docs/get-started/installation/)
k6 run scripts/k6-api-benchmark.js

# Consolidated regression check (reads reports/ directory)
npm run perf:regression
node scripts/checkPerfRegression.js

# Update baseline from latest reports
npm run perf:update-baseline
Expand All @@ -114,7 +167,7 @@ npm run perf:update-baseline
The regression threshold defaults to **5%**. To change it:

- **CI**: set the `REGRESSION_THRESHOLD` env var in the workflow step
- **Local**: `REGRESSION_THRESHOLD=10 npm run perf:regression`
- **Local**: `REGRESSION_THRESHOLD=10 node scripts/checkPerfRegression.js`

---

Expand Down
Loading
Loading