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
11 changes: 3 additions & 8 deletions apps/storybook/src/Foundations.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -708,13 +708,7 @@ function EaseRow({ name }: { name: string }) {
// A preset player - clicking Play remounts the sample with the literal `animate-*` class so
// the ACTUAL Tailwind utility runs. `animationName` is the preset's label, also used as the
// badge text; the remount (key bump) restarts the "out" presets so they visibly animate away.
function PresetPlayer({
cls,
animationName,
}: {
cls: string;
animationName: string;
}) {
function PresetPlayer({ cls, animationName }: { cls: string; animationName: string }) {
const [tick, setTick] = useState(0);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
Expand Down Expand Up @@ -876,7 +870,8 @@ function Motion() {
style={{
fontSize: 'var(--text-sm)',
color: 'var(--color-text)',
background: 'color-mix(in srgb, var(--color-warning) 12%, var(--color-surface))',
background:
'color-mix(in srgb, var(--color-warning) 12%, var(--color-surface))',
borderLeft: '3px solid var(--color-warning)',
borderRadius: 'var(--radius-sm)',
padding: '0.5rem 0.75rem',
Expand Down
33 changes: 33 additions & 0 deletions docs/feedback/0017-form-controls-ios-zoom.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions docs/overview/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,3 +535,16 @@ or a mid-flight glitch. For any spring/overshoot demo, size the stage for the **
`travel x max-bezier-y`, add clearance) and prove the extreme is in-bounds by frame-sampling the
live animation or freezing at the computed peak. Never ship a motion affordance you have not
watched fire (a CSS `:hover` re-declaring an identical `animation-name` restarts nothing).

## Form controls must default to >=16px on mobile (iOS auto-zoom)

iOS Safari auto-zooms the page when a focused `<input>`/`<textarea>`/`<select>` has a computed
font-size below 16px. `Input`, `Textarea`, and the `Select` trigger all shipped `text-sm` (14px), so
every consumer hit the zoom on phones - surfaced downstream on rogueoak.com, then fixed at the source
(feedback 0017), not per-app.

**Apply it:** a focusable form control's mobile font-size must be >=16px. Default the field controls
to `text-base md:text-sm` (16px on phones, 14px from `md` up) rather than a bare `text-sm`; a
design-system default is a stronger guarantee than asking each consumer to override. This is a
platform rule, not an aesthetic choice - verify the computed size, and remember `md` (width) is the
right proxy since iOS zoom is a small-viewport phone behavior.
9 changes: 9 additions & 0 deletions packages/canopy/src/seeds/Input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Input aria-label="Field" />);
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(<Input aria-label="Field" />);
Expand Down
5 changes: 4 additions & 1 deletion packages/canopy/src/seeds/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
8 changes: 8 additions & 0 deletions packages/canopy/src/seeds/Select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Basic />);
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(<Basic />);
Expand Down
5 changes: 4 additions & 1 deletion packages/canopy/src/seeds/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,11 @@ const SelectTrigger = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
// Trigger font is `text-base md:text-sm` (16px mobile, 14px from md up): iOS Safari auto-zooms
// a focused control under 16px, so the trigger matches Input/Textarea (feedback 0017). The
// dropdown items below stay text-sm - they are not focusable text fields, so they never zoom.
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-border bg-surface px-3 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',
'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}
Expand Down
7 changes: 7 additions & 0 deletions packages/canopy/src/seeds/Textarea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ describe('Textarea', () => {
);
});

it('renders at 16px on mobile and 14px from md up (iOS anti-zoom, feedback 0017)', () => {
render(<Textarea aria-label="Field" />);
const textarea = screen.getByRole('textbox');
expect(textarea).toHaveClass('text-base', 'md:text-sm');
expect(textarea).not.toHaveClass('text-sm');
});

it('typing updates the value', async () => {
const user = userEvent.setup();
render(<Textarea aria-label="Field" />);
Expand Down
5 changes: 4 additions & 1 deletion packages/canopy/src/seeds/Textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ import { cn } from '../lib/cn';
* multi-line, and `resize-y` so a reader can drag the field taller (height otherwise follows
* the native `rows` prop).
*/
// Font size is `text-base md:text-sm` (16px mobile, 14px from md up): iOS Safari auto-zooms a
// focused field under 16px, so phones get 16px and larger viewports keep the denser 14px
// (feedback 0017), matching Input.
const textareaBase =
'flex w-full rounded-md border border-border bg-surface px-3 py-2 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 min-h-20 resize-y';
'flex w-full rounded-md border border-border bg-surface px-3 py-2 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 min-h-20 resize-y';

/**
* TextareaProps - the native `<textarea>` attributes, verbatim. Textarea adds no bespoke props
Expand Down
Loading