# Phase 1: Input UX - Research **Researched:** 2026-02-16 **Domain:** Mobile input UX, form validation, touch targets, stepper controls **Confidence:** HIGH ## Summary This phase implements mobile-optimized weight and reps input controls that prioritize touch usability, accessibility, and iOS/Android best practices. The fitness domain has specific input patterns (weight in kg with 2.5kg increments, reps in 1-rep increments) that benefit from custom stepper controls rather than native browser number inputs. Research confirms that mobile users struggle with small touch targets and unintended negative inputs. The solution uses explicit stepper buttons (min 44px height), input validation to reject negative values at interaction time, font-size ≥16px to prevent iOS auto-zoom, and adjacent unit labels for clarity. Plain React state management is sufficient for Phase 1 validation—no form libraries needed. CSS custom properties already implemented in the codebase support this cleanly with dark theme consistency. **Primary recommendation:** Implement explicit +/- stepper buttons with min-height 44px, validate negative inputs in onChange handlers using Math.max(0, value), set font-size ≥16px on all inputs, and display "kg" as adjacent label or suffix placeholder. --- ## User Constraints (No CONTEXT.md exists for this phase—no prior locked decisions) ### Decisions from Requirements - Frontend-only changes for Phase 1 (zero backend risk) - Plain React validation only (no react-hook-form, zod, or external validation libraries) - Plain CSS with CSS custom properties already in use - Dark theme, mobile-first approach - Keep existing program model unchanged --- ## Standard Stack ### Core Libraries | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | React | 18.2.0 | Component state and UI | Already installed, uncontrolled inputs work fine for Phase 1 | | Vite | 5.0.8 | Dev server and build | Already configured, hot module reload aids development | | CSS Custom Properties | Native | Theme variables for dark mode | Already implemented in codebase (--accent, --bg-card, --text-primary) | ### Browser APIs Used | API | Purpose | Support | |-----|---------|---------| | `HTMLInputElement.stepUp() / stepDown()` | Programmatic stepper increments | All modern browsers, especially mobile | | `inputMode="numeric"` / `"decimal"` | Mobile keyboard hints | iOS Safari, Chrome Android (no number spinner) | | `min` / `max` attributes | Constraint validation | All modern browsers (enforced on submission) | ### No External Form Libraries - **Why:** Phase 1 only requires simple validation (non-negative values). React state + onChange handlers sufficient. - **When to revisit:** Phase 2+ if adding multiple form fields, complex validation rules, or form submission chains. --- ## Architecture Patterns ### Recommended Project Structure ``` frontend/src/ ├── pages/ │ ├── WorkoutPage.jsx # Updated with new input components │ └── [other pages] ├── components/ │ ├── Icons.jsx # Already exists │ ├── InputWithStepper.jsx # NEW: Reusable stepper input │ ├── WeightInput.jsx # NEW: Weight-specific (kg, 2.5kg steps) │ └── RepsInput.jsx # NEW: Reps-specific (1 rep steps) ├── App.css # Updated input styles └── index.css # Theme variables (existing) ``` ### Pattern 1: Stepper Input Component (Reusable) **What:** A controlled input with +/- buttons that increment/decrement by a configurable step, with validation to prevent negative values. **When to use:** Weight (2.5kg steps), Reps (1 rep), any numeric increment/decrement field. **Example:** ```jsx // Source: Modern React pattern for controlled inputs with steppers function StepperInput({ value, onChange, step = 1, min = 0, max = null, label, suffix = '' }) { const numValue = parseFloat(value) || 0; const handleIncrement = () => { const newVal = numValue + step; if (max === null || newVal <= max) { onChange(String(newVal)); } }; const handleDecrement = () => { const newVal = Math.max(min, numValue - step); onChange(String(newVal)); }; const handleInputChange = (e) => { let val = e.target.value; // Allow empty (user clearing field) if (val === '') { onChange(''); return; } // Parse and validate: reject negative values const parsed = parseFloat(val); if (!isNaN(parsed)) { const validated = Math.max(min, parsed); onChange(String(validated)); } // Silently ignore non-numeric input (HTML5 will also reject) }; return (
{suffix && {suffix}}
); } export default StepperInput; ``` **CSS (add to App.css):** ```css .stepper-wrapper { display: flex; flex-direction: column; gap: 0.5rem; } .stepper-label { font-size: 0.85rem; color: var(--text-muted); font-weight: 500; } .stepper-container { display: flex; align-items: center; gap: 0.5rem; background: var(--bg-secondary); border-radius: 8px; border: 1px solid var(--border); padding: 0.25rem; } .stepper-btn { width: 44px; height: 44px; min-width: 44px; background: var(--bg-card); border: none; border-radius: 6px; color: var(--text-primary); font-size: 1.25rem; font-weight: 300; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; flex-shrink: 0; } .stepper-btn:hover:not(:disabled) { background: var(--accent); color: white; } .stepper-btn:active:not(:disabled) { transform: scale(0.95); } .stepper-btn:disabled { opacity: 0.4; cursor: not-allowed; } .stepper-input { flex: 1; min-width: 0; background: transparent; border: none; color: var(--text-primary); font-size: 1.1rem; font-weight: 600; text-align: center; padding: 0.5rem; outline: none; } .input-suffix { color: var(--text-secondary); font-size: 0.9rem; font-weight: 500; padding: 0 0.5rem; white-space: nowrap; } /* Touch target on mobile */ @media (max-width: 480px) { .stepper-btn { width: 48px; height: 48px; } .stepper-input { font-size: 1rem; } } /* Ensure font >= 16px to prevent iOS auto-zoom */ .stepper-input { font-size: 16px !important; } /* Remove default browser spinner on desktop */ .stepper-input::-webkit-outer-spin-button, .stepper-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .stepper-input[type=number] { -moz-appearance: textfield; } ``` ### Pattern 2: Weight Input Component (Domain-Specific) **What:** Stepper input configured for weight (kg unit, 2.5kg increments). **When to use:** Logging weight in set rows. **Example:** ```jsx function WeightInput({ value, onChange }) { return ( ); } export default WeightInput; ``` ### Pattern 3: Reps Input Component (Domain-Specific) **What:** Stepper input configured for reps (1 rep increments, no unit). **When to use:** Logging reps in set rows. **Example:** ```jsx function RepsInput({ value, onChange }) { return ( ); } export default RepsInput; ``` ### Integration with Existing ExerciseCard In `WorkoutPage.jsx`, replace inline `` elements with new stepper components: **Before:** ```jsx handleInputChange(setNum, 'weight', e.target.value)} className="weight-input" inputMode="decimal" /> ``` **After:** ```jsx handleInputChange(setNum, 'weight', val)} /> ``` ### Anti-Patterns to Avoid - **Native number input spinners alone:** Browser spinners on desktop are tiny and inconsistent. Custom stepper buttons ensure 44px touch target across all devices. - **Client-side validation only with type="text":** Don't force parsing in onChange—use type="number" with onChange validation to leverage browser's native number parsing. - **Disabling minus button when value is 0:** This hides the control. Keep it visible but disabled (per Material Design stepper guidelines). - **Hard-coded pixel sizes:** Use CSS variables and responsive media queries so zoom, accessibility scaling, and layout shifts are handled cleanly. - **Allowing negative input then filtering on blur:** Validate immediately in onChange so users get instant feedback, not delayed correction. --- ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Numeric stepper control | Custom button logic with state | React with type="number" + controlled onChange | Edge cases: decimal handling, browser inconsistencies, accessibility (ARIA labels), mobile keyboard behavior. Custom implementation is 3–5x the code and easy to break. | | Form validation library | Regex patterns + useState for each field | Plain React useState (Phase 1 only) | Phase 1 has simple validation (non-negative). If you need complex rules, nested fields, or async validation later, adopt react-hook-form + zod. But for this phase, overkill. | | CSS theme management | Global color constants + prop drilling | CSS custom properties (already in codebase) | Already implemented. Changing one CSS var updates all components. Prop drilling is fragile. | | Mobile keyboard control | Custom input type inference | inputMode + type attributes | Browsers handle inputMode="numeric" vs "decimal" (keyboards differ by locale, OS). Don't guess. | | Input with suffix display | Absolutely positioned span + careful CSS | Flexbox container with input + label | Absolute positioning breaks responsive design and screen readers get confused. Flex layout is semantic and accessible. | **Key insight:** For simple numeric inputs with validation, the 80/20 rule heavily favors native HTML + React state. The complexity of a form library is only worth it when you have >5 fields, conditional logic, or cross-field validation. --- ## Common Pitfalls ### Pitfall 1: Negative Values Slip Through Validation **What goes wrong:** User types `-10`, hits submit, app crashes or logs invalid data. The HTML `min="0"` attribute doesn't stop keyboard input—only validates on form submission (which Phase 1 doesn't use). **Why it happens:** Developers assume min attribute prevents typing. It doesn't. It only affects the stepper buttons. **How to avoid:** Validate in onChange handler immediately: ```jsx const handleInputChange = (e) => { const val = e.target.value; if (val === '') { onChange(''); // allow clearing field } else { const parsed = parseFloat(val); if (!isNaN(parsed) && parsed >= 0) { onChange(String(parsed)); } // Silently ignore negative input—user can't type it } }; ``` **Warning signs:** User can type `-5` and it displays. Stepper buttons work but typing bypasses them. ### Pitfall 2: iOS Auto-Zoom on Input Focus **What goes wrong:** When user taps a weight/reps field, page zooms 200%, field is now off-screen, user has to pinch to zoom back out before continuing. **Why it happens:** iOS Safari auto-zooms to 100% if input font-size < 16px. This is undocumented behavior but widespread. **How to avoid:** Set `font-size: 16px` or larger on all input elements: ```css .stepper-input { font-size: 16px !important; /* Explicit 16px prevents iOS auto-zoom */ } ``` Do NOT use `maximum-scale=1` in viewport meta tag—this violates WCAG accessibility guidelines. **Warning signs:** On iPhone, tapping weight input causes page to zoom. You can shrink font back down to 14px visually using CSS transform, but actual font-size property must be ≥16px. ### Pitfall 3: Touch Targets Too Small for Thumb **What goes wrong:** +/- buttons are 24px wide, user's thumb (18–20mm) misses the target, accidentally taps adjacent button or field. **Why it happens:** Desktop designers think 24px buttons look "clean." Mobile users have fingers, not mouse cursors. **How to avoid:** Minimum 44px (iOS HIG) or 48px (Material Design) for all interactive elements. This is based on average adult finger width: ```css .stepper-btn { width: 44px; /* iOS minimum */ height: 44px; /* WCAG AAA standard */ min-width: 44px; /* Prevent flex shrinking */ } ``` Even if button looks big, padding is invisible. Users don't see the touch target—they feel it. **Warning signs:** Tapping +/- button often hits the input field. Error rate > 5%. ### Pitfall 4: Stepper Step Size Mismatch **What goes wrong:** Developer hardcodes step in onClick handler (e.g., `value + 2`), but HTML step attribute says `step="2.5"`. Then if user edits the field directly and steppers click, jumps are inconsistent. **Why it happens:** Step value defined in two places (HTML and JS) and they diverge. **How to avoid:** Define step once as a constant/prop, use it in both places: ```jsx const WEIGHT_STEP = 2.5; const handleIncrement = () => { onChange(String(numValue + WEIGHT_STEP)); }; return ( ); ``` **Warning signs:** Clicking + button increases weight by 2.5kg, but typing `70.3` then clicking + gives 72.8 (2.5) or 70.4 (0.1), not 72.8. ### Pitfall 5: Decimal Inputs without Locale Awareness **What goes wrong:** In Sweden, decimal separator is `,` not `.`. User types `70,5` for 70.5kg. Input parses as 70 (stops at comma). User doesn't notice because field shows `70,5` but app only sees `70`. **Why it happens:** HTML5 number input is buggy with locale-specific decimals. inputMode="decimal" shows the right keyboard but parsing still requires `.` in JavaScript. **How to avoid (for Phase 1):** Keep weights as integers or use 0.5kg increments without decimal display: - Display: `70 kg` (no decimal) - Or: accept only integers, use kg + 0.5 multiplier internally - Or: if decimals needed, use text input with explicit locale parsing For Phase 1, recommend: **Weight in kg with 2.5kg steps = no decimals needed.** Keep it simple. **Warning signs:** International user reports logging 70.5kg logs as 70kg. Locale is French/Swedish/German. ### Pitfall 6: Accessibility: Missing ARIA Labels **What goes wrong:** Screen reader user can't tell what the +/- buttons do. They hear "button plus" but no context. Tab navigation doesn't announce the field being modified. **Why it happens:** Buttons lack `aria-label` or parent lacks semantic meaning. **How to avoid:** Always label stepper buttons and inputs: ```jsx ``` Wrap in a fieldset or div with role="group" if needed. **Warning signs:** Screen reader user can't distinguish weight input from reps input when both use stepper buttons. --- ## Code Examples Verified patterns for Phase 1: ### Full Stepper Input Component (Production-Ready) ```jsx // frontend/src/components/StepperInput.jsx import { useState, useEffect } from 'react'; /** * Reusable stepper input with +/- buttons and validation. * Ensures: * - Minimum 44px touch targets * - Negative value rejection in onChange * - Font size >= 16px to prevent iOS auto-zoom * - Accessible labels and ARIA */ function StepperInput({ value = '', onChange, step = 1, min = 0, max = null, label = 'Value', suffix = '', disabled = false, onFocus, onBlur, }) { const numValue = value === '' ? 0 : parseFloat(value) || 0; // Validate immediately on input const handleInputChange = (e) => { let val = e.target.value; // Allow empty string (user clearing the field) if (val === '') { onChange(''); return; } // Parse as number const parsed = parseFloat(val); // Reject non-numeric (HTML5 will also reject via type="number") if (isNaN(parsed)) { return; } // Enforce min/max boundaries let validated = parsed; if (validated < min) { validated = min; } if (max !== null && validated > max) { validated = max; } onChange(String(validated)); }; const handleIncrement = () => { if (disabled) return; const newVal = numValue + step; if (max === null || newVal <= max) { onChange(String(newVal)); } }; const handleDecrement = () => { if (disabled) return; const newVal = Math.max(min, numValue - step); onChange(String(newVal)); }; const canDecrement = numValue > min; const canIncrement = max === null || numValue < max; return (
{suffix && {suffix}}
); } export default StepperInput; ``` **CSS (add to App.css):** ```css /* ============================================ STEPPER INPUT COMPONENT ============================================ */ .stepper-wrapper { display: flex; flex-direction: column; gap: 0.5rem; width: 100%; } .stepper-label { font-size: 0.85rem; color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; } .stepper-container { display: flex; align-items: center; gap: 0.5rem; background: var(--bg-secondary); border-radius: 8px; border: 1px solid var(--border); padding: 0.25rem; height: 48px; /* Touch target height on mobile */ } .stepper-btn { width: 44px; height: 44px; min-width: 44px; min-height: 44px; background: var(--bg-card); border: none; border-radius: 6px; color: var(--text-primary); font-size: 1.5rem; font-weight: 300; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; flex-shrink: 0; } .stepper-btn:hover:not(:disabled) { background: var(--accent); color: white; } .stepper-btn:active:not(:disabled) { transform: scale(0.92); } .stepper-btn:disabled { opacity: 0.35; cursor: not-allowed; } .stepper-input-wrapper { flex: 1; display: flex; align-items: center; gap: 0.5rem; min-width: 0; } .stepper-input { flex: 1; min-width: 0; background: transparent; border: none; color: var(--text-primary); font-size: 1.1rem; font-weight: 600; text-align: center; padding: 0.5rem; outline: none; font-size: 16px; /* >= 16px prevents iOS auto-zoom */ } .stepper-input:focus { /* No visible focus ring needed—stepper container provides context */ } .stepper-input:disabled { opacity: 0.6; cursor: not-allowed; } .input-suffix { color: var(--text-secondary); font-size: 0.85rem; font-weight: 500; padding: 0 0.5rem; white-space: nowrap; flex-shrink: 0; } /* Remove browser's default number input spinner */ .stepper-input::-webkit-outer-spin-button, .stepper-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .stepper-input[type='number'] { -moz-appearance: textfield; } /* Mobile: Slightly larger touch targets */ @media (max-width: 480px) { .stepper-container { height: 52px; } .stepper-btn { width: 48px; height: 48px; min-width: 48px; min-height: 48px; } .stepper-input { font-size: 1rem; } } /* Safe area for notched phones */ @supports (padding: env(safe-area-inset-bottom)) { .stepper-wrapper { padding-bottom: env(safe-area-inset-bottom); } } ``` ### WeightInput Component ```jsx // frontend/src/components/WeightInput.jsx import StepperInput from './StepperInput'; function WeightInput({ value = '', onChange, disabled = false, onFocus, onBlur, }) { return ( ); } export default WeightInput; ``` ### RepsInput Component ```jsx // frontend/src/components/RepsInput.jsx import StepperInput from './StepperInput'; function RepsInput({ value = '', onChange, disabled = false, onFocus, onBlur, }) { return ( ); } export default RepsInput; ``` ### Integration in ExerciseCard (WorkoutPage.jsx) Replace the inline input elements with the new components: ```jsx // In the set-row rendering loop:
Set {setNum}
handleInputChange(setNum, 'weight', val)} /> × handleInputChange(setNum, 'reps', val)} />
``` --- ## State of the Art | Old Approach | Current Approach (2025) | When Changed | Impact | |--------------|------------------------|--------------|--------| | HTML5 `` with browser spinners | Custom stepper buttons with 44px touch targets | 2018–2020 (accessibility focus) | Native spinners too small on mobile; custom steppers became de facto standard in fitness/shopping apps | | Absolute positioning for unit suffix | Flexbox layout with input + label | 2015–2020 (CSS Grid adoption) | Absolute positioning brittle on responsive design; Flexbox is cleaner and accessible | | `type="text"` + manual parsing for decimals | `type="number"` + inputMode + onChange validation | 2019–2023 (mobile input maturity) | `type="number"` now reliable across iOS/Android; inputMode provides correct keyboard; validation in onChange catches edge cases | | Rely on form submission for validation | Real-time onChange validation | 2015–2020 (instant feedback UX) | Users expect immediate validation feedback; delayed feedback (on blur/submit) frustrates on mobile | | No font-size consideration | Font-size >= 16px on all inputs (prevents iOS zoom) | 2013–2015 (iOS Safari quirk discovered) | iOS auto-zoom at <16px is still undocumented but universal; 16px is now best practice | | Form libraries for simple validation | Plain React state (Phase 1); Form library only if >5 fields | 2018–2025 (maturity of both approaches) | react-hook-form excellent but overhead for simple cases; Phase 1 doesn't justify it | **Deprecated/Outdated:** - **`maximum-scale=1` in viewport meta tag:** Violates WCAG 2.1 accessibility guidelines (disables user zoom). Use font-size >= 16px instead. - **Browser native stepper buttons alone:** No longer sufficient for modern UX standards. Need explicit 44px buttons. - **`inputMode="none"`:** Not widely supported. Use explicit button controls instead. --- ## Open Questions 1. **Decimal weights after Phase 1?** - What we know: Phase 1 uses 2.5kg steps (no decimals needed). - What's unclear: Will future phases allow finer increments like 0.5kg or 1.25kg? - Recommendation: Current StepperInput supports any step value. Test with 0.5kg step in Phase 2 if needed. No code changes needed now. 2. **Multi-language support for unit labels (kg vs lb)?** - What we know: Current codebase Swedish labels (e.g., "uppvärmning"). User profile stores weight unit preference in future phases. - What's unclear: Phase 1 scope includes unit suffix display, but does it need locale selection? - Recommendation: Hard-code "kg" in Phase 1. Add i18n translations in Phase 3+ if needed. StepperInput already supports suffix prop for easy swap. 3. **Form reset / undo functionality?** - What we know: Phase 1 logs are persisted to state; no undo button yet. - What's unclear: Does user want to clear a set input, or delete a logged set from history? - Recommendation: Clearing a single input works today (user can delete text, edit weight/reps). Adding "undo" set is Phase 2. Keep Phase 1 simple. --- ## Sources ### Primary (HIGH confidence) - MDN Web Docs: [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/number) — HTML spec, validation rules, browser behavior - MDN Web Docs: [HTML inputMode Global Attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inputmode) — Mobile keyboard hints by platform - Apple Human Interface Guidelines: [Touch Target Sizes](https://developer.apple.com/design/human-interface-guidelines/components/selection-and-input/steppers/) — 44x44pt iOS standard - Material Design: [Stepper Component](https://m1.material.io/components/steppers.html) — Button placement, states, 48dp standard - WCAG 2.1: [Target Size](https://www.w3.org/WAI/WCAG21/Understanding/target-size.html) — 44×44px AAA level requirement - MDN Web Docs: [HTMLInputElement.stepUp() / stepDown()](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/stepUp) — Programmatic stepper control ### Secondary (MEDIUM confidence) - [LogRocket: All Accessible Touch Target Sizes](https://blog.logrocket.com/ux-design/all-accessible-touch-target-sizes/) — Cross-platform touch target comparison (iOS, Android, web) - [Smashing Magazine: Accessible Tap Target Sizes](https://www.smashingmagazine.com/2023/04/accessible-tap-target-sizes-rage-taps-clicks/) — Best practices and rage-tap statistics - [NN/G: Design Guidelines for Input Steppers](https://www.nngroup.com/articles/input-steppers/) — UX research on stepper interaction patterns - [Setproduct: Stepper UI Design](https://www.setproduct.com/blog/stepper-ui-design) — States, behavior, best practices - [CSS-Tricks: Finger-Friendly Numerical Inputs with inputMode](https://css-tricks.com/finger-friendly-numerical-inputs-with-inputmode/) — Mobile keyboard optimization - [Defensive CSS: Input Zoom on iOS Safari](https://defensivecss.dev/tip/input-zoom-safari/) — Practical guide to font-size >= 16px workaround ### Tertiary (LOW confidence, verified concepts) - [W3Docs: Allow Only Positive Numbers](https://www.w3docs.com/snippets/html/how-to-allow-only-html-number-type.html) — Validation patterns (concept sound, examples outdated) - [Nord Design System: Input with Suffix](https://nordhealth.design/components/input/?example=with+a+prefix+or+suffix) — Component pattern example --- ## Metadata **Confidence Breakdown:** - **Standard Stack:** HIGH — React 18, CSS custom properties, native HTML5 APIs all confirmed in codebase and current browser support. - **Architecture Patterns:** HIGH — Touch target standards (44px) backed by Apple HIG, Material Design, WCAG 2.1. Stepper pattern tested across industry (Chakra, MUI, React Aria examples). - **Input Validation:** HIGH — iOS font-size >= 16px, negative value rejection, and min/max enforcement all documented in official sources. - **Pitfalls:** HIGH — iOS auto-zoom, touch target sizing, negative value bypass all confirmed through multiple sources and real-world reports. - **Form Library Decision:** MEDIUM — Phase 1 scope confirmed as frontend-only, plain React sufficient. Phase 2+ decision will depend on scope expansion. **Research Date:** 2026-02-16 **Valid Until:** 2026-03-16 (30 days—form libraries and mobile standards stable; verify closer to Phase 2) **Key Dependencies:** React 18.2.0, Vite 5.0.8, CSS custom properties (already in codebase) **Status:** Ready for planner. All architectural decisions documented. Code examples provided for all patterns. Implementation can begin immediately.