iOS focus-zoom prevention — input font-size floor @ 16px ; JS fallback strengthened
Belt-and-suspenders for the iOS Safari auto-zoom-on-input quirk: Mobile Safari zooms the viewport when an `<input>`/`<textarea>`/`<select>` is focused & its computed font-size < 16px, and never zooms back out on blur. Two layers ; PRIMARY — SCSS prevention: new `input, textarea, select, [contenteditable] { font-size: unquote("max(16px, 1em)") }` in core.scss (Sass can't reconcile px/em units in compile-time max() so unquote() passes the CSS max() through verbatim — modern browsers handle natively). 1em inherits parent, max() floors at 16. ALSO floored `.form-control-lg` in _base.scss — was `font-size: 1.125rem`, which at rem=14 (small portrait, clamp(14px, 2.4vmin, 22px) hits its floor) computes to 15.75px → **0.25px** under iOS's 16px threshold → the "ever so slightly" zoom on New Game + New Post applets the user reported (both use `.form-control.form-control-lg`, specificity 0,2,0 beats my element-level 0,0,1 rule). Floor: `unquote("max(16px, 1.125rem)")` ; SECONDARY — JS fallback in base.html: rewritten from `setAttribute('content', ...)` toggle to full meta-element remove+re-add, which modern iOS handles more reliably than attribute mutations on the existing meta. Triggers on document-level `focusout` (bubbles natively, no capture-phase needed) for `input/textarea/select`; injects fresh viewport meta w. `maximum-scale=1.0, user-scalable=no` for 300ms (iOS reads as zoom violation → snaps to 1:1), then swaps back to the cached base content so pinch-zoom remains available elsewhere ; user observed horizontal scrollbar appearing when the page zoomed — that's the symptom the user actually cared about (broken layout, not aesthetic zoom). w. SCSS floor in place the zoom shouldn't trigger to begin with; the JS is purely for inputs that slip through (future custom controls, shadow DOM, etc.) ; iOS-specific behavior — Selenium+Firefox doesn't replicate the auto-zoom so no FT layer added. Verified by user manual iPhone test (post-fix retest pending after force-refresh)
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -159,7 +159,11 @@ body {
|
||||
&.form-control-lg {
|
||||
--_pad-v: 0.75rem;
|
||||
padding: var(--_pad-v) 1rem;
|
||||
font-size: 1.125rem;
|
||||
// 1.125rem at rem=14 (small portrait clamp floor) is 15.75px
|
||||
// — just under iOS Safari's 16px auto-zoom threshold. Floor
|
||||
// at 16px to prevent the focus-zoom; native CSS max() handles
|
||||
// the unit mix Sass can't reconcile at compile time.
|
||||
font-size: unquote("max(16px, 1.125rem)");
|
||||
}
|
||||
|
||||
&.is-invalid ~ .invalid-feedback {
|
||||
|
||||
@@ -23,4 +23,11 @@ select,
|
||||
[contenteditable] {
|
||||
user-select: text;
|
||||
touch-action: auto;
|
||||
// iOS Safari auto-zooms when focusing a form field whose computed font-size
|
||||
// is < 16px. At rem=14 (small portrait viewports w. clamp(14,2.4vmin,22))
|
||||
// a 1rem input is 14px → triggers zoom. `max(16px, 1em)` enforces the 16px
|
||||
// floor while still inheriting larger sizes from parent contexts when set.
|
||||
// unquote() keeps Sass from trying to evaluate the px/em compare at compile
|
||||
// time (it can't reconcile units); CSS max() handles it natively at runtime.
|
||||
font-size: unquote("max(16px, 1em)");
|
||||
}
|
||||
@@ -101,17 +101,31 @@
|
||||
|
||||
// iOS Safari auto-zooms when focusing an <input>/<textarea>/<select>
|
||||
// whose font-size is < 16px, and does NOT auto-zoom back out on blur.
|
||||
// Briefly toggling maximum-scale=1.0 on the viewport meta forces iOS to
|
||||
// reset to 1:1; reverting after 100ms restores the default content so
|
||||
// users can still pinch-zoom manually elsewhere on the page.
|
||||
// Belt-and-suspenders: primary prevention is the global `font-size:
|
||||
// max(16px, 1em)` rule in core.scss; this JS is a fallback for inputs
|
||||
// that slip through (custom controls, shadow DOM, etc.). Modern iOS
|
||||
// doesn't reliably react to a simple `setAttribute('content', ...)`
|
||||
// tweak on the existing meta — removing and re-appending the meta
|
||||
// element entirely is more dependable.
|
||||
(function () {
|
||||
var vp = document.querySelector('meta[name="viewport"]');
|
||||
if (!vp) return;
|
||||
var base = vp.getAttribute('content');
|
||||
var origMeta = document.querySelector('meta[name="viewport"]');
|
||||
if (!origMeta) return;
|
||||
var baseContent = origMeta.getAttribute('content');
|
||||
document.addEventListener('focusout', function (e) {
|
||||
if (!e.target.matches || !e.target.matches('input, textarea, select')) return;
|
||||
vp.setAttribute('content', base + ', maximum-scale=1.0');
|
||||
setTimeout(function () { vp.setAttribute('content', base); }, 100);
|
||||
var oldMeta = document.querySelector('meta[name="viewport"]');
|
||||
if (oldMeta) oldMeta.remove();
|
||||
var snapMeta = document.createElement('meta');
|
||||
snapMeta.setAttribute('name', 'viewport');
|
||||
snapMeta.setAttribute('content', baseContent + ', maximum-scale=1.0, user-scalable=no');
|
||||
document.head.appendChild(snapMeta);
|
||||
setTimeout(function () {
|
||||
snapMeta.remove();
|
||||
var revertMeta = document.createElement('meta');
|
||||
revertMeta.setAttribute('name', 'viewport');
|
||||
revertMeta.setAttribute('content', baseContent);
|
||||
document.head.appendChild(revertMeta);
|
||||
}, 300);
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user