Skip to content

perf(mobile): CWV fixes + editor mobile UX improvements#561

Open
aafre wants to merge 5 commits into
mainfrom
feat/mobile-cwv-improvements
Open

perf(mobile): CWV fixes + editor mobile UX improvements#561
aafre wants to merge 5 commits into
mainfrom
feat/mobile-cwv-improvements

Conversation

@aafre
Copy link
Copy Markdown
Owner

@aafre aafre commented May 30, 2026

Summary

Mobile-focused performance and UX improvements targeting Core Web Vitals and editor experience on phones. No visual changes on desktop.

CLS fixes (Cumulative Layout Shift)

  • Typewriter animation: width: 0→100%clip-path: inset() — was a layout-triggering property causing CLS on landing page
  • Header auth slot: min-width: 80px wrapper around Sign In / UserMenu — prevents reflow when auth state resolves
  • Editor save-status slot: min-width wrapper around AutoSaveIndicator/AnonymousWarningBadge — prevents shift when user logs in mid-session
  • ResumeCard image: height="400"height="192" to match the actual h-48 container (was mismatched, causing CLS on /my-resumes)
  • App shell skeleton: Better 2-line H1 at 48px height, 2-line subtitle, corrected button height (56px) — reduces delta on React hydration

INP / interaction improvements

  • 300ms tap delay eliminated: touch-action: manipulation added globally on all interactive elements — direct INP win on iOS/Android
  • -webkit-tap-highlight-color: Moved from inline style on every button → single CSS rule (DRY, covers all elements)
  • iOS input zoom fix: font-size: 16px on all inputs/textareas at max-width: 767px — iOS Safari auto-zooms inputs < 16px, which breaks editor flow on iPhones

LCP / navigation

  • Speculation Rules API: Added prerender for top routes at moderate eagerness — page navigations become near-instant on Chromium (LCP ~0ms for predicted navigations)

Mobile UX / editor

  • MobileNavigationDrawer: Swipe-left-to-close gesture (60px threshold), tighter max-w-[75vw] (was 80vw, too wide on small phones)
  • Safe-area CSS: Moved from dangerouslySetInnerHTML in MobileActionBar → styles.css (cleaner, removes runtime injection)
  • PreviewModal: h-[92dvh] (dynamic viewport height respects mobile browser chrome) + pb-[env(safe-area-inset-bottom)] for iPhone notch
  • TemplateSelectionModal: p-3 on mobile (was p-2, too tight), max-h-[92dvh]
  • Section cards (editor): Tighter padding p-4 sm:p-6 lg:p-8 (was always p-6 sm:p-8), reduced mb-4 sm:mb-8, added accent green left border (border-l-4 border-l-accent) for visual hierarchy on all section types
  • ExperienceItem entries: Tighter p-3 sm:p-6 on mobile

Test plan

  • CLS: Open Chrome DevTools → Lighthouse → mobile audit, check CLS score before/after
  • iOS zoom: Open editor on iPhone (or Chrome DevTools mobile emulation), tap any input field — should NOT zoom
  • Swipe drawer: On editor mobile view, open section drawer then swipe left — should close
  • Tap delay: Tap buttons on mobile — should feel instant (no 300ms lag)
  • PreviewModal: Open PDF preview on mobile — should not be cut off by browser chrome or notch
  • Section cards: Editor on mobile — section cards should have tight padding and accent green left border
  • Speculation Rules: Navigate between pages on Chrome — should feel faster on second navigation
  • Desktop regression: All changes are mobile-only or CSS-isolated — desktop layout unchanged

aafre added 4 commits May 30, 2026 07:34
- Add touch-action:manipulation globally to eliminate 300ms tap delay on
  iOS/Android (direct INP improvement)
- Move -webkit-tap-highlight-color to CSS (was inline on every button)
- Move safe-area-inset-bottom CSS to styles.css (remove dangerouslySetInnerHTML)
- Add swipe-left-to-close gesture to MobileNavigationDrawer
- Tighten drawer max-width to 75vw (was 80vw) for phones < 375px
- Fix typewriter animation: width→clip-path (no longer shifts layout)
- Reserve fixed min-width on Header auth slot: prevents shift when auth resolves
- Reserve fixed min-width on editor save-status slot: prevents badge swap shift
- Fix ResumeCard img height="400"→"192" to match h-48 container (CLS on /my-resumes)
- Improve app shell skeleton: 2-line H1 at 48px, 2-line subtitle, correct button height
  so hydration delta is smaller on mobile
- PreviewModal: h-[92dvh] (dynamic viewport height respects mobile browser
  chrome) + pb-[env(safe-area-inset-bottom)] for iPhone notch
- TemplateSelectionModal: p-3 on mobile (was p-2, too tight), max-h-[92dvh]
- Add Speculation Rules prerender for top routes (moderate eagerness);
  makes navigation LCP near-instant on Chromium
…t borders

- Fix iOS auto-zoom: add font-size:16px on all inputs/textareas on mobile
  (iOS Safari zooms inputs with font-size < 16px, breaking the editor UX)
- Tighten section card padding: p-6 sm:p-8 → p-4 sm:p-6 lg:p-8 on all
  section types (Experience, Education, Generic, IconList, Contact)
- Reduce section bottom margin: mb-8 → mb-4 sm:mb-8 (more sections visible
  without scrolling on small phones)
- Add accent green left border (border-l-4 border-l-accent) to all section
  cards — polished visual hierarchy matching the design system
- Tighten ExperienceItem entry card: p-6 mb-6 → p-3 sm:p-6 mb-3 sm:mb-6
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request focuses on mobile responsiveness, layout stability, and performance optimizations, including skeleton loader updates, speculation rules, and touch interaction improvements. The review feedback highlights several issues: an invalid URL pattern in the speculation rules causing a TypeError, Tailwind specificity overriding the mobile input zoom fix, accidental drawer closures during vertical scrolling, layout gaps in the header, redundant safe-area padding in the preview modal, and a visual regression in the typewriter animation.

Comment thread resume-builder-ui/index.html Outdated
<script type="speculationrules">
{
"prerender": [{
"where": { "href_matches": "/(|templates|my-resumes|jobs|blog|examples|resume-keywords)" },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The empty alternative | in the URLPattern group /(|templates|...) is invalid according to the URL Pattern API specification. This will cause the browser to throw a TypeError when parsing the speculation rules, silently disabling the prerendering optimization entirely.

To fix this, make the entire group optional using a trailing ? instead of an empty alternative.

Suggested change
"where": { "href_matches": "/(|templates|my-resumes|jobs|blog|examples|resume-keywords)" },
"where": { "href_matches": "/(templates|my-resumes|jobs|blog|examples|resume-keywords)?" },

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gemini-code-assist Fixed in 9ae1f54 — changed to /(templates|my-resumes|jobs|blog|examples|resume-keywords)? with trailing ?. Good catch, this was silently breaking all prerendering.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for confirming the fix, aafre. That change to the URL Pattern group correctly addresses the issue and ensures the speculation rules will be parsed as expected by the browser.

Comment on lines +421 to +427
@media screen and (max-width: 767px) {
input:not([type="range"]),
textarea,
select {
font-size: 16px;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Because Tailwind utility classes like text-sm (which apply font-size: 0.875rem) have higher CSS specificity than element selectors, any input or textarea with a font-size utility class will override this global rule and remain under 16px on mobile. This will prevent the iOS Safari auto-zoom fix from working on those fields (such as the inline list inputs in GenericSection.tsx which use text-sm).

Using !important here ensures that the 16px minimum is enforced on mobile regardless of any utility classes applied to the elements.

@media screen and (max-width: 767px) {
  input:not([type="range"]),
  textarea,
  select {
    font-size: 16px !important;
  }
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gemini-code-assist Fixed in 9ae1f54 — added !important with an inline comment explaining why (Tailwind utilities win on specificity without it).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the update, aafre. Using !important in this context is the correct approach to ensure the 16px minimum font size is respected on mobile devices, overriding any conflicting Tailwind utility classes. This is a robust fix for the iOS Safari auto-zoom issue.

Comment on lines +55 to +64
const touchStartX = useRef<number>(0);

const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
};

const handleTouchEnd = (e: React.TouchEvent) => {
const dx = e.changedTouches[0].clientX - touchStartX.current;
if (dx < -60) onClose();
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current swipe-to-close implementation only checks the horizontal delta (dx < -60). If a user scrolls vertically through the drawer's section list and their finger drifts slightly to the left, the drawer will accidentally close.

To prevent this, track both touchStartX and touchStartY and ensure that the horizontal swipe is the dominant gesture (e.g., Math.abs(dx) > Math.abs(dy) * 1.5) before triggering onClose().

Suggested change
const touchStartX = useRef<number>(0);
const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
};
const handleTouchEnd = (e: React.TouchEvent) => {
const dx = e.changedTouches[0].clientX - touchStartX.current;
if (dx < -60) onClose();
};
const touchStartX = useRef<number>(0);
const touchStartY = useRef<number>(0);
const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
};
const handleTouchEnd = (e: React.TouchEvent) => {
const dx = e.changedTouches[0].clientX - touchStartX.current;
const dy = e.changedTouches[0].clientY - touchStartY.current;
if (dx < -60 && Math.abs(dx) > Math.abs(dy) * 1.5) onClose();
};

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gemini-code-assist Fixed in 9ae1f54 — added touchStartY ref and the Math.abs(dx) > Math.abs(dy) * 1.5 dominance check so a diagonal scroll no longer closes the drawer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the update, aafre. The implementation looks correct and addresses the issue where diagonal scrolls could trigger the close action. The addition of the touchStartY ref and the dominance check (Math.abs(dx) > Math.abs(dy) * 1.5) is the standard and robust way to handle this in mobile web environments.

</>
)}
{/* Auth UI - User Menu or Sign In Button — fixed min-width prevents CLS on auth resolve */}
<div className="flex items-center" style={{ minWidth: '80px', minHeight: '40px' }}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using a hardcoded inline minWidth: '80px' on the auth UI container can create an unnecessary empty layout gap on mobile devices where the "Sign In" button or user avatar is smaller than 80px.

Using responsive Tailwind classes instead of inline styles allows you to specify a smaller minimum width on mobile and a larger one on desktop, keeping the layout tight and aligned.

Suggested change
<div className="flex items-center" style={{ minWidth: '80px', minHeight: '40px' }}>
<div className="flex items-center min-w-[50px] lg:min-w-[80px] min-h-[36px] lg:min-h-[40px]">
References
  1. When abstracting styles with a fixed set of values, prefer discrete utility classes over CSS custom properties to avoid inline styles entirely.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gemini-code-assist Fixed in 9ae1f54 — replaced inline style with min-w-[50px] lg:min-w-[80px] min-h-[36px] lg:min-h-[40px].

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the update, aafre. The implementation looks correct and addresses the CLS concern while maintaining responsive behavior. No further action is needed on this thread.

>
<div
className="bg-white rounded-t-2xl lg:rounded-2xl shadow-2xl w-full lg:max-w-5xl lg:mx-4 h-[95vh] lg:h-[90vh] flex flex-col animate-slide-up lg:animate-scale-in"
className="bg-white rounded-t-2xl lg:rounded-2xl shadow-2xl w-full lg:max-w-5xl lg:mx-4 h-[92dvh] lg:h-[90vh] flex flex-col animate-slide-up lg:animate-scale-in pb-[env(safe-area-inset-bottom)]"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Adding pb-[env(safe-area-inset-bottom)] to the parent container here results in double safe-area padding on iOS devices because there is already an existing safe-area spacer div at the bottom of the modal footer (line 272): <div style={{ paddingBottom: 'max(0.5rem, env(safe-area-inset-bottom))' }} />.

To fix this, you should either remove pb-[env(safe-area-inset-bottom)] from this container, or keep it and remove the redundant spacer div at line 272.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gemini-code-assist Fixed in 9ae1f54 — removed pb-[env(safe-area-inset-bottom)] from the container. The max(0.5rem, env(safe-area-inset-bottom)) spacer div already handles this more robustly (guarantees minimum 0.5rem padding too).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update, aafre. That approach is indeed more robust as it guarantees the minimum padding while correctly handling the safe area insets. Since you've already addressed the redundancy, this looks good.

Comment on lines 227 to 230
@keyframes typewriter {
from { width: 0; }
to { width: 100%; }
from { clip-path: inset(0 100% 0 0); }
to { clip-path: inset(0 0% 0 0); }
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While changing the typewriter animation to use clip-path instead of width is excellent for preventing layout shifts (CLS), it introduces a visual regression: the blinking caret (which relies on border-right or a pseudo-element) will now remain static at the far right of the container instead of moving along with the text as it is typed.

If the moving caret is important for the effect, consider animating the caret's position using a GPU-accelerated transform: translateX() on a pseudo-element, or document this trade-off in the code.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gemini-code-assist Acknowledged — added a comment in 9ae1f54 documenting this trade-off (CLS fix takes priority over a moving caret). Not changing the animation since the CLS gain is more impactful than the visual regression.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood. Prioritizing CLS reduction is a sound architectural decision for performance-critical mobile web experiences. Since the trade-off is now documented in the codebase, this approach is acceptable.

- index.html: fix invalid URLPattern empty-alternative /(|...) → /(...)?
  (TypeError was silently disabling all Speculation Rules prerendering)
- styles.css: add !important to iOS 16px font-size fix so Tailwind
  utilities like text-sm can't override it on mobile
- styles.css: document clip-path typewriter trade-off (caret stays fixed)
- MobileNavigationDrawer: add touchStartY tracking + horizontal-dominance
  check (Math.abs(dx) > Math.abs(dy) * 1.5) to prevent accidental close
  during vertical scroll
- Header: replace inline minWidth/minHeight style with responsive Tailwind
  min-w-[50px] lg:min-w-[80px] min-h-[36px] lg:min-h-[40px]
- PreviewModal: remove duplicate pb-[env(safe-area-inset-bottom)] from
  container — the existing max(0.5rem, env(...)) spacer div already handles it
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant