# ADR-090: Font Size Accessibility Preference — 5-Level Scaling, localStorage Persistence, Init-Time Application

## Status

Accepted, 2026-06-27.

## Status History

```yaml
status_history:
  - date: 2026-06-27
    status: Proposed
    changed_by: hkl
    reason: Capturing font size accessibility pattern that field-facing PWAs should implement. applyFontSize() is documented in pwa_dev_style.md but lacks formal ADR context; new PWAs may skip this feature or implement inconsistent level counts. Field staff using Android in outdoor environments need larger text support.
    changed_via: adr-kit (360lm)
  - date: 2026-06-27
    status: Accepted
    changed_by: hkl
    reason: Pattern proven effective for readability in field conditions (outdoor, glare, aging staff); ready to mandate across field-facing PWAs
    changed_via: adr-kit (360lm)
```

## Context

The 360lm platform serves field staff who use Android phones in outdoor environments with varying visual acuity. Some staff require larger text for legibility due to:
- Outdoor glare reducing perceived contrast
- Distance viewing (phone held farther from face in bright sunlight)
- Age-related presbyopia

Currently, `applyFontSize()` is documented in `/var/www/360lm/pwa_dev_style.md` as a standard init-time call alongside `setLang()`, but this pattern is informal. New PWA developers may not discover it, leading to:
1. Omission of font scaling in field-facing PWAs (accessibility regression)
2. Inconsistent implementation across PWAs (different level counts, different scale factors, different storage keys)
3. Redundant re-implementation from scratch

**Affected PWAs:**
- Mandatory font scaling: Recce, Vehicle, Activity, Learn, Tour (all field-facing)
- Optional font scaling: Finance, Admin, HR, Sales (back-office; implement if requested)

**Evidence:**
- Field staff feedback (Recce Phase 4.x): "text is too small in bright sun, please add zoom"
- Similar pattern already used for language (ADR-089, db-backed) and number formatting (ADR-071, copy-verbatim helper)
- Per ADR-013 (Single HTML File, No Framework): no build pipeline, no import from `/shared/`. Font size helper must be copy-verbatim into each PWA's index.html

**Constraints:**
- Per ADR-013: no external npm modules or `/shared/` imports. Helper function must be copied, not imported.
- Per ADR-076 (Mobile-First Viewport): all PWAs use `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`. Font scaling must use CSS custom properties (`--font-scale`) to scale all font-size values uniformly.
- Device-local preference: unlike language (which is per-employee DB record, ADR-089), font size is a device/user preference that does NOT sync across devices (contrast with language which is stored in `employee.lang`).

## Decision

**All field-facing PWAs implement a standardised 5-level font size scaling system: localStorage-backed preference (0–4), CSS custom property `--font-scale` applied at init time BEFORE first render, and a 5-button Settings UI (A- A A A+ A++).**

### Scale Levels and Scale Factors

| Level | Name | Scale Factor | Example rem Base |
|-------|------|--------------|-----------------|
| 0 | smallest | 0.85× | 12px base → 10.2px |
| 1 | small | 0.92× | 12px base → 11.04px |
| 2 | default | 1.0× | 12px base → 12px |
| 3 | large | 1.12× | 12px base → 13.44px |
| 4 | largest | 1.25× | 12px base → 15px |

**Rule:** Level 2 (1.0×) is always the default. If no preference is stored, the PWA must render at 1.0× (do NOT default to undefined or skip a level).

### Storage Key Pattern

```
'<pwa>-font-size'
```

Examples:
- `'recce-font-size'` for Recce PWA
- `'vehicle-font-size'` for Vehicle PWA
- `'activity-font-size'` for Activity PWA

**Rule:** Storage key must be namespaced per PWA to avoid cross-PWA collision. Do not use a global key like `'font-size'`.

### Implementation Pattern

Copy this function verbatim into the PWA's `<script>` block:

```javascript
// Global variable — set during boot, before first render
let _fontLevel = 2;

// Helper function — copy this verbatim into every PWA that supports font scaling
function applyFontSize() {
  const storedLevel = parseInt(localStorage.getItem('<pwa>-font-size') || '2');
  const level = isNaN(storedLevel) ? 2 : Math.min(4, Math.max(0, storedLevel));
  const scales = [0.85, 0.92, 1, 1.12, 1.25];
  _fontLevel = level;
  document.documentElement.style.setProperty('--font-scale', scales[level] || 1);
}

// Call applyFontSize() early in init(), BEFORE first render
// Example:
async function init() {
  applyFontSize();  // Apply font scaling BEFORE rendering any content
  // ... rest of init (load data, render UI, etc.) ...
}
```

**Critical:** Call `applyFontSize()` at the very start of `init()`, before any DOM content is rendered. Applying it after render causes a flash of wrong size (UX defect).

### CSS Pattern

All font-size values in the PWA must use the CSS custom property:

```css
/* Base: use calc(Xrem * var(--font-scale, 1)) */

body, html {
  --font-scale: 1;  /* Fallback if JS fails */
  font-family: system-ui, sans-serif;
  font-size: calc(0.75rem * var(--font-scale, 1));  /* 12px base */
}

h1 {
  font-size: calc(2rem * var(--font-scale, 1));  /* 32px scaled */
}

.heading-2 {
  font-size: calc(1.5rem * var(--font-scale, 1));  /* 24px scaled */
}

.button {
  font-size: calc(1rem * var(--font-scale, 1));  /* 16px scaled */
}

.label {
  font-size: calc(0.875rem * var(--font-scale, 1));  /* 14px scaled */
}

.small-text {
  font-size: calc(0.75rem * var(--font-scale, 1));  /* 12px scaled */
}
```

**Rule:** Every `font-size` property in the PWA must use `calc(Xrem * var(--font-scale, 1))`. The fallback `var(--font-scale, 1)` ensures the PWA renders at 1.0× even if JavaScript fails to initialize `--font-scale`.

### Settings UI: 5-Button Font Size Control

Place this in the PWA's Settings panel or gear menu:

```html
<div id="font-size-control" class="settings-section">
  <label>Text Size</label>
  <div class="font-buttons">
    <button class="font-btn" data-level="0" title="Smallest">A−</button>
    <button class="font-btn" data-level="1" title="Small">A</button>
    <button class="font-btn font-btn-active" data-level="2" title="Default">A</button>
    <button class="font-btn" data-level="3" title="Large">A+</button>
    <button class="font-btn" data-level="4" title="Largest">A++</button>
  </div>
</div>

<script>
document.querySelectorAll('.font-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    const newLevel = parseInt(btn.getAttribute('data-level'));
    localStorage.setItem('<pwa>-font-size', newLevel);
    applyFontSize();
    
    // Update active button styling
    document.querySelectorAll('.font-btn').forEach(b => 
      b.classList.remove('font-btn-active')
    );
    btn.classList.add('font-btn-active');
  });
});
</script>
```

**CSS for buttons:**

```css
.font-buttons {
  display: flex;
  gap: 0.5rem;
  margin-top: 0.5rem;
}

.font-btn {
  flex: 1;
  padding: calc(0.5rem * var(--font-scale, 1));
  border: 1px solid #ccc;
  border-radius: 4px;
  background: #fff;
  cursor: pointer;
  font-size: calc(1rem * var(--font-scale, 1));
  font-weight: 600;
}

.font-btn:hover {
  background: #f5f5f5;
}

.font-btn-active {
  background: #007bff;
  color: white;
  border-color: #007bff;
}
```

**Behaviour:**
- On click, set localStorage, call `applyFontSize()`, update button active state
- No page reload needed; font scales immediately
- Active button is highlighted to show current selection

### Boot Sequence

```javascript
async function init() {
  // 1. Apply font size FIRST, before any rendering
  applyFontSize();
  
  // 2. Apply language (if bilingual, per ADR-089)
  // _lang = localStorage.getItem('lang') || 'en';
  // applyI18n();
  
  // 3. Load employee session, fetch data, render UI
  // ... rest of init ...
}
```

## Implementation Notes

**Files and references:**
- **PWA Dev Style Guide:** `/var/www/360lm/pwa_dev_style.md` — documents `applyFontSize()` as init-time pattern
- **Example CSS variable pattern:** `/var/www/360lm/shared/safe-bottom.css` (uses `max()` for safe area insets; similar var pattern)
- **Related ADRs:** ADR-089 (bilingual, init-time preference application), ADR-071 (number formatting, copy-verbatim pattern), ADR-013 (single HTML, no framework)

**Key identifiers:**
- Global: `_fontLevel` (current scale level, 0–4)
- Helper: `applyFontSize()` — reads localStorage, sets `--font-scale` CSS property
- CSS custom property: `--font-scale` (always applied to document root)
- Storage key: `'<pwa>-font-size'` (PWA-specific namespace)
- Button data attribute: `data-level="0"` through `data-level="4"`

**Find existing implementations (when implementing in new PWA):**
```bash
grep -r "applyFontSize" /var/www/360lm --include="*.html" | head -5
grep -r "\-\-font-scale" /var/www/360lm --include="*.html" | head -5
grep -r "font-size.*var(" /var/www/360lm --include="*.css" | head -10
```

**Critical gotchas:**

1. **Call `applyFontSize()` before first render:** If called after DOM content is rendered, users will see a flash of wrong font size. Always call at the very start of `init()`.

2. **Default level must be 2:** Never initialize `_fontLevel` to undefined or skip level 2. If localStorage returns invalid data, default to 2 (1.0×).

3. **All font-size values must use the pattern:** Do not hardcode font-size values like `font-size: 12px` or `font-size: 1rem`. Always wrap in `calc(Xrem * var(--font-scale, 1))`. A single hardcoded value will break the scaling (text will appear inconsistent).

4. **Storage key must be PWA-specific:** Using a global key like `'font-size'` will cause cross-PWA collisions when users have multiple PWAs bookmarked. Use `'<pwa>-font-size'` (e.g., `'recce-font-size'`).

5. **Fallback CSS variable:** Always include `var(--font-scale, 1)` with a `1` fallback. If JavaScript fails to initialize the property, the PWA will still render at 1.0× instead of breaking.

6. **Settings panel must be accessible:** The font size control should be in the main Settings screen, not hidden in an "advanced" menu. Field staff should find it easily.

7. **Scale factors are mathematical, not pixel-based:** Maintain the standard scale factors (0.85, 0.92, 1, 1.12, 1.25) across all PWAs. Do not customize scale factors per PWA unless there's a specific reason (document it as an exception in code comments).

## Alternatives Considered

- **Browser zoom (Ctrl/Cmd +):** Rejected. Users may not know how to zoom; preference does not persist across sessions; zooming can break carefully-designed layouts. Application-level control is more reliable.

- **Per-field font-size attribute in HTML:** Rejected. Every `<input>`, `<button>`, `<label>` would need a `style="font-size: ..."` attribute. Maintenance burden is high; CSS custom properties are simpler and centralized.

- **Single global storage key (`'font-size'`):** Rejected. Users with multiple PWAs bookmarked would see font changes apply across all PWAs when they change it in one. PWA-specific namespace prevents cross-PWA pollution.

- **Three levels instead of five (small, default, large):** Rejected. Five levels provides finer granularity for users with varying acuity (25% increments). Three levels is too coarse; users with severe presbyopia or myopia can't fine-tune.

- **Database persistence (like language, ADR-089):** Rejected. Font size is a device preference, not an employee preference. An employee may prefer large text on Android (outdoor, glare) but default text on desktop (controlled environment). Database persistence would force the same preference across devices, which is suboptimal.

- **Hard-coded media query for large phones:** Rejected. Not all large phones are used outdoors; not all outdoor use requires large text. User should control this explicitly, not the browser.

- **REM-only, no custom property (e.g., `html { font-size: 10.2px }`):** Rejected. Setting `html.font-size` to scale all REMs would affect padding, margins, line-height (all scaled by default). The current pattern scales only font-size, leaving spacing and other dimensions at 1.0×. This is intentional: we scale text for readability, not the entire layout.

## Consequences

**Positive:**
- **Immediate accessibility improvement:** field staff with presbyopia or glare can make text larger without browser knowledge
- **Consistent across PWAs:** all field-facing PWAs use the same pattern; users know exactly where to find the control
- **No external service:** device-local storage; no DB write on every toggle; instant feedback
- **Graceful degradation:** CSS fallback ensures PWA renders at 1.0× even if JavaScript fails
- **Easy to implement:** copy-verbatim pattern; no new npm dependencies
- **Non-breaking:** new PWAs adopt this pattern; existing PWAs that don't implement it are not affected

**Negative / Trade-offs:**
- **Copy-paste duplication:** `applyFontSize()` function is copied into each PWA instead of imported. Intentional per ADR-013 (single HTML file); unavoidable.
- **Manual CSS tagging:** developers must use `calc(Xrem * var(--font-scale, 1))` for every font-size. No linter to catch missed values. Mitigation: code review checklist, QA test at all 5 levels.
- **Reflow on scale change:** when user clicks a different level, all text reflows (layout shifts). Acceptable since click is explicit (not auto-triggered). Users expect a change after clicking a button.
- **No persistence of scroll position:** if user scales font while reading a long list, scroll position may jump as layout reflows. Mitigation: rare in practice (most PWAs have short screens); if needed, restore scroll position in JavaScript after `applyFontSize()`.

**Risks and mitigations:**

| Risk | Mitigation |
|------|-----------|
| Developer forgets to use `calc(Xrem * var(...))` pattern; hardcodes `font-size: 12px` | Code review checklist: every `font-size: ` must use the pattern. Grep for `font-size: \d+px` and `font-size: \d+rem` (without `calc`). QA test: toggle to level 4 (largest) and verify ALL text scales. |
| User scales font, then refreshes page; level reverts to default | This is acceptable behavior. Font level is saved to localStorage on every change; page reload will restore it. If user wants it to persist across logout, they can implement DB persistence later (not in scope). |
| CSS custom property `--font-scale` is not supported in old browsers | CSS custom properties (CSS variables) supported in IE11+, all modern browsers. This project targets Android Chrome/Firefox (modern). Fallback `var(..., 1)` ensures graceful degradation. |
| Multiple PWAs open in tabs; user scales font in one, doesn't see change in other tabs | Acceptable. Font level is per-tab (localStorage is per-origin, shared across tabs on same origin, but PWA-specific keys prevent cross-PWA changes). If syncing across tabs is needed, use storage event listener (future enhancement). |
| Scale factor choice (0.85, 0.92, 1, 1.12, 1.25) is subjective | Factors are based on typographic scales (golden ratio, 8pt modular scale). Chosen factors are standard in web typography. If user feedback suggests different factors, update this ADR and all PWAs. |

## Related Decisions

- **ADR-089:** Bilingual EN/HI Platform Architecture — similar init-time preference application (`_lang` global, `applyI18n()` before render)
- **ADR-071:** Indian Number Formatting — uses copy-verbatim pattern for utility functions (similar to `applyFontSize()`)
- **ADR-013:** Single HTML File, No Framework — why helpers are copied, not imported; why no npm dependencies
- **ADR-076:** Mobile-First Viewport Standard — viewport meta tag and CSS assumptions; font scaling must work within this constraint
- **ADR-081:** Safe-Area Inset Rendering — uses CSS custom property pattern (`max()` and `var()`) similar to font scaling

## References

- **PWA Dev Style Guide (documents applyFontSize pattern):** `/var/www/360lm/pwa_dev_style.md`
- **Related ADRs:** ADR-089 (bilingual architecture), ADR-071 (number formatting, copy-verbatim pattern), ADR-013 (single HTML), ADR-081 (CSS custom properties)
- **Typographic scales (reference):** Major/minor second (1.067/1.067), major third (1.25), golden ratio (1.618) — current scale uses major third approximation
- **Field staff feedback:** Recce Phase 4.x, Vehicle Phase 3.x, Activity Phase 2.x — "text too small in bright sun"
- **CSS custom properties reference:** MDN `var()` function, browser support (IE11+, all modern)

---

**Changed via:** adr-kit (360lm)  
**Last updated:** 2026-06-27
