From 36f180a9a07f24343ae90e03fb965b1728ce6919 Mon Sep 17 00:00:00 2001 From: Matthew Maynes Date: Tue, 14 Jul 2026 10:22:44 -0400 Subject: [PATCH 1/3] fix(seeds): default form controls to 16px on mobile to prevent iOS zoom iOS Safari auto-zooms the page when a focused form control's font-size is below 16px. Input, Textarea, and the Select trigger all used text-sm (14px), so every consumer hit this on phones. Set them to `text-base md:text-sm`: 16px on mobile (no zoom), 14px from md up (keeps the denser desktop typography). Dropdown items stay text-sm - they are not focusable fields. Adds a test per control; captured in docs/feedback/0017. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01NYM2qa9QBSvQYBGP3pATQK --- docs/feedback/0017-form-controls-ios-zoom.md | 33 ++++++++++++++++++++ packages/canopy/src/seeds/Input.test.tsx | 9 ++++++ packages/canopy/src/seeds/Input.tsx | 5 ++- packages/canopy/src/seeds/Select.test.tsx | 8 +++++ packages/canopy/src/seeds/Select.tsx | 5 ++- packages/canopy/src/seeds/Textarea.test.tsx | 7 +++++ packages/canopy/src/seeds/Textarea.tsx | 5 ++- 7 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 docs/feedback/0017-form-controls-ios-zoom.md diff --git a/docs/feedback/0017-form-controls-ios-zoom.md b/docs/feedback/0017-form-controls-ios-zoom.md new file mode 100644 index 0000000..8edbc07 --- /dev/null +++ b/docs/feedback/0017-form-controls-ios-zoom.md @@ -0,0 +1,33 @@ +# 0017 - Form controls auto-zoom on iOS + +Source: rogueoak.com subscribe form (rogueoak feedback 0002) - tapping a canopy `Input` on a phone +zoomed the page. The same latent bug is in every canopy consumer, so it is fixed at the source here. + +## Symptom + +On iOS Safari, focusing a canopy `Input`, `Textarea`, or `Select` trigger zoomed the whole page in - +a jarring shift the user then had to undo. Reproduced on rogueoak.com; applies to any consumer with +these controls on a phone. + +## Root cause + +iOS Safari auto-zooms to a focused form control whenever its computed `font-size` is below **16px**. +All three field controls used `text-sm` (14px) in their base class, so every one tripped the zoom. +A platform rule the component defaults did not account for - not a consumer bug, so a per-app +override (what rogueoak shipped first) is the wrong layer. The design system owns the default. + +## Fix + +Set the focusable field controls to `text-base md:text-sm`: **16px on mobile** (no iOS zoom) and +**14px from the `md` breakpoint up** (preserves the denser desktop typography). Applied to `Input`, +`Textarea`, and the `Select` trigger. The `SelectItem` / `SelectLabel` dropdown rows stay `text-sm` - +they are not focusable text fields, so they never zoom. `md` is a width breakpoint, not a touch test, +but that is the right proxy here: iOS auto-zoom is a small-viewport phone behavior, and phones sit +below `md`. Each control gains a test asserting `text-base md:text-sm` (and not bare `text-sm`). + +## Learning + +**Interactive form controls must default to font-size >= 16px on mobile.** A 16px minimum on +focusable fields is a design-system-level guarantee, not a per-app override - fix it once in the +component so every consumer inherits it. Use `text-base md:text-sm` when the desktop design wants a +denser 14px. Verify the computed size, not just the class. diff --git a/packages/canopy/src/seeds/Input.test.tsx b/packages/canopy/src/seeds/Input.test.tsx index f133499..3e13d0a 100644 --- a/packages/canopy/src/seeds/Input.test.tsx +++ b/packages/canopy/src/seeds/Input.test.tsx @@ -20,6 +20,15 @@ describe('Input', () => { expect(input).toHaveClass('border', 'border-border', 'bg-surface', 'text-text', 'rounded-md'); }); + it('renders at 16px on mobile and 14px from md up (iOS anti-zoom, feedback 0017)', () => { + render(); + const input = screen.getByRole('textbox'); + // text-base (16px) is the mobile default so iOS Safari does not auto-zoom on focus; + // md:text-sm restores the denser 14px on larger, non-zooming viewports. + expect(input).toHaveClass('text-base', 'md:text-sm'); + expect(input).not.toHaveClass('text-sm'); + }); + it('typing updates the value', async () => { const user = userEvent.setup(); render(); diff --git a/packages/canopy/src/seeds/Input.tsx b/packages/canopy/src/seeds/Input.tsx index fd4ec06..da421b9 100644 --- a/packages/canopy/src/seeds/Input.tsx +++ b/packages/canopy/src/seeds/Input.tsx @@ -19,7 +19,10 @@ export const inputVariants = cva( // not text-subtle: subtle is AA-Large-only and placeholder is small text - review 0006); the // focus-visible ring (a11y); the disabled token pair (not opacity); and the aria-invalid // danger overrides for border + ring. - 'flex w-full rounded-md border border-border bg-surface px-3 text-sm text-text placeholder:text-text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-ring-offset disabled:cursor-not-allowed disabled:bg-disabled disabled:text-disabled-foreground aria-invalid:border-danger aria-invalid:ring-danger', + // Font size is `text-base md:text-sm`: 16px on mobile, 14px from md up. iOS Safari auto-zooms + // the page when a focused field is below 16px, so the base font must be >=16px on phones; the + // md breakpoint restores the denser 14px on larger (non-zooming) viewports (feedback 0017). + 'flex w-full rounded-md border border-border bg-surface px-3 text-base md:text-sm text-text placeholder:text-text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-ring-offset disabled:cursor-not-allowed disabled:bg-disabled disabled:text-disabled-foreground aria-invalid:border-danger aria-invalid:ring-danger', { variants: { size: { diff --git a/packages/canopy/src/seeds/Select.test.tsx b/packages/canopy/src/seeds/Select.test.tsx index b70dd9e..4280b72 100644 --- a/packages/canopy/src/seeds/Select.test.tsx +++ b/packages/canopy/src/seeds/Select.test.tsx @@ -55,6 +55,14 @@ describe('Select', () => { expect(trigger).toHaveClass('border-border', 'bg-surface', 'text-text', 'rounded-md', 'h-10'); }); + it('the trigger renders at 16px on mobile and 14px from md up (iOS anti-zoom, feedback 0017)', () => { + render(); + const trigger = screen.getByRole('combobox'); + // Input-parity: 16px on phones so iOS does not auto-zoom the focused trigger, 14px from md up. + expect(trigger).toHaveClass('text-base', 'md:text-sm'); + expect(trigger).not.toHaveClass('text-sm'); + }); + it('opens the listbox and shows the options on click', async () => { const user = userEvent.setup(); render(); diff --git a/packages/canopy/src/seeds/Select.tsx b/packages/canopy/src/seeds/Select.tsx index 50c21a0..66dc4cf 100644 --- a/packages/canopy/src/seeds/Select.tsx +++ b/packages/canopy/src/seeds/Select.tsx @@ -50,8 +50,11 @@ const SelectTrigger = React.forwardRef< >(({ className, children, ...props }, ref) => ( span]:line-clamp-1', + 'flex h-10 w-full items-center justify-between rounded-md border border-border bg-surface px-3 text-base md:text-sm text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-ring-offset disabled:cursor-not-allowed disabled:bg-disabled disabled:text-disabled-foreground aria-invalid:border-danger aria-invalid:ring-danger data-[placeholder]:text-text-muted [&>span]:line-clamp-1', className, )} {...props} diff --git a/packages/canopy/src/seeds/Textarea.test.tsx b/packages/canopy/src/seeds/Textarea.test.tsx index 00696fd..3ccfccb 100644 --- a/packages/canopy/src/seeds/Textarea.test.tsx +++ b/packages/canopy/src/seeds/Textarea.test.tsx @@ -25,6 +25,13 @@ describe('Textarea', () => { ); }); + it('renders at 16px on mobile and 14px from md up (iOS anti-zoom, feedback 0017)', () => { + render(