Skip to content

fix(nav,map): iOS PWA bottom-nav safe area + map controls clearance and zoom-to-fit - #4499

Merged
Yeraze merged 2 commits into
mainfrom
fix/ios-pwa-bottom-nav-4497
Aug 2, 2026
Merged

fix(nav,map): iOS PWA bottom-nav safe area + map controls clearance and zoom-to-fit#4499
Yeraze merged 2 commits into
mainfrom
fix/ios-pwa-bottom-nav-4497

Conversation

@Yeraze

@Yeraze Yeraze commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Closes #4497, #4495, #4496.

Two commits: #4497 is a nav bug; #4495/#4496 both touch .map-controls and the issues ask for their placement to be coordinated, so they land together.


#4497 — iOS PWA bottom nav

Reported on an iPhone 14 Pro Max standalone install.

A. Safe-area padding was zeroed — a regression I introduced in #4484

The padding-bottom: 0 !important I added to .sidebar (to beat the two landscape-rail blocks that also set padding with !important) also beat SourceNav's own padding-bottom: env(safe-area-inset-bottom). The bar docked flush to the screen edge, putting the tab row inside the iOS home-indicator swipe zone — so taps near it ran the system gesture instead of hitting a tab.

I flagged at the time that the !important was load-bearing for the landscape case; I didn't check what else it outranked.

Now env(safe-area-inset-bottom) !important, which satisfies both constraints. Nothing else shifts — App.css already reserves --app-nav-bar-height + safe-area-inset-bottom, and .save-bar / .nodes-split-view offset by that same sum.

B. Horizontal scroll could eject the user from the PWA

Adds touch-action: pan-x and overscroll-behavior-x: contain so a drag on the bar can't chain out to Safari's back-swipe / app-switcher edge gesture.

Partial by nature, stated plainly: the iOS home-indicator and app-switcher gestures are OS-level and CSS cannot suppress them. Fix A is what actually lifts the tap targets clear of the gesture zone; B only stops the scroll chaining. Worth confirming on real hardware — a true standalone PWA can't be reproduced in desktop Chrome.

C. No cue that the bar scrolls

Edge fades driven by the measured scroll position, so a bar whose tabs all fit shows no fade at all. Uses mask-image rather than an ::after overlay, because a pseudo-element inside a scroll container scrolls away with the content.


#4495 — map controls covered by the attribution

z-index cannot fix this, and the measurement proves it: the attribution lives in Leaflet's own .leaflet-bottom at z-index: 1000, while .map-controls must stay at 997 to yield to the Sources drawer (999). 997 < 1000, so wherever they overlap the attribution wins.

Bounding the panel's height means they never overlap at all, with the body scrolling when the feature list is long (the base rule is overflow: hidden, so the excess would otherwise just be clipped).

Verified at 390×780: panel bottom 515, attribution top 707, overlap: false.

Deliberately not the icon-row redesign the reporter floated — the position fix addresses the complaint without a layout rethink, and keeps the existing drag behaviour and muscle memory. That redesign remains open as its own idea.

#4496 — zoom to fit all nodes

A target-icon button in the controls header. It sits in the header so it stays reachable while the panel is collapsed: one-tap re-framing is the point, and click-to-expand-first would spoil it.

.map-controls is a sibling of MapContainer with no access to the map instance, so this follows the shape already in the file (TracerouteBoundsController): a controller inside the map reacting to a prop. The prop is a monotonically increasing counter, not a boolean, so repeated taps re-fit even when the node set hasn't changed.

Fits to the offset marker positions, matching the pins the user actually sees, per the #4016/#4155 single-position rule — fitting raw centres could leave a visible pin just outside the viewport. The single-node case centres rather than calling fitBounds, which snaps to max zoom on zero-area bounds.

Verified live: zoomed in until 0 of 206 markers were on screen; one tap returned 176 / 176 positioned nodes to view with padding.


Test plan

Note for reviewers

One of the new tests initially anchored its CSS lookup on a phrase that also appears in an earlier cross-reference comment, so it sliced in the base .map-controls rule instead of the mobile override and failed. Fixed to lastIndexOf. Mentioning it because src/styles/nodes.css has a documented history of exactly this cascade-order confusion (#3532).


Generated by Claude Code

Yeraze and others added 2 commits August 1, 2026 17:49
…le nav (#4497)

Three iOS-PWA bugs on the bottom bar, reported on an iPhone 14 Pro Max
standalone install.

A. Safe-area padding was zeroed — a regression from #4484. The
`padding-bottom: 0 !important` added to `.sidebar` (to beat the two
landscape-rail blocks that set padding with !important) also beat
SourceNav's own `padding-bottom: env(safe-area-inset-bottom)`, docking the
bar flush to the screen edge. In a standalone PWA that puts the tab row
inside the home-indicator swipe zone, so taps ran the system gesture instead
of hitting the tab. Now `env(safe-area-inset-bottom) !important`, which
satisfies both constraints. Nothing else shifts: App.css already reserves
`--app-nav-bar-height + safe-area-inset-bottom`, and .save-bar /
.nodes-split-view offset by that same sum.

B. Horizontal scroll could chain out to Safari's back-swipe / app-switcher
edge gesture and eject the user from the PWA. Adds `touch-action: pan-x` and
`overscroll-behavior-x: contain`. Partial by nature — the iOS
home-indicator and app-switcher gestures are OS-level and CSS cannot
suppress them; A is what actually lifts the targets clear of the gesture
zone, B only stops the scroll chaining.

C. Nothing indicated the bar scrolls, so tabs were missed entirely. Adds
edge fades driven by the measured scroll position, so a bar whose tabs all
fit shows no fade. Uses `mask-image` rather than an ::after overlay because
a pseudo-element inside a scroll container scrolls away with the content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
…-all (#4495, #4496)

Both touch `.map-controls`; the issues ask for the placement to be
coordinated, so they land together.

#4495 — the controls panel was partly covered by the Leaflet attribution on
mobile. Raising z-index cannot fix it: the attribution lives in Leaflet's own
`.leaflet-bottom` (z-index 1000, measured), while this panel must stay at 997
to yield to the Sources drawer (999). 997 < 1000, so wherever they overlap
the attribution wins. Bounding the panel's height means they never overlap at
all, with the body scrolling when the feature list is long (the base rule is
`overflow: hidden`, so without that the excess would just be clipped).

Verified at 390x780: panel bottom 515, attribution top 707, overlap false.

Deliberately not the icon-row redesign the reporter floated — the position
fix addresses the complaint without a layout rethink, and leaves the existing
drag behaviour and muscle memory intact.

#4496 — adds a zoom-to-fit-all-nodes button. It sits in the controls header
so it stays reachable while the panel is collapsed; one-tap re-framing is the
whole point, and click-to-expand-first would spoil it.

`.map-controls` is a SIBLING of MapContainer with no access to the map
instance, so this follows the file's existing shape (TracerouteBoundsController):
a controller inside the map reacting to a prop. The prop is a monotonically
increasing counter, not a boolean, so repeated taps re-fit even when the node
set hasn't changed.

Fits to the OFFSET marker positions, matching the pins the user actually
sees, per the #4016/#4155 single-position rule — fitting to raw centres could
leave a visible pin just outside the viewport. Single-node case centres
instead of fitBounds, which would snap to max zoom on zero-area bounds.

Verified live: zoomed in until 0 of 206 markers were on screen, one tap
returned all 176 positioned nodes to view with padding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: fix(nav,map): iOS PWA bottom-nav safe area + map controls clearance and zoom-to-fit

  • Read changed files
  • Review code quality and best practices
  • Check for potential bugs or issues
  • Evaluate performance considerations
  • Assess security concerns
  • Evaluate test coverage

This is a well-engineered PR that correctly identifies and fixes three distinct problems. The implementation quality is high throughout — good rationale in comments, measured over assumed for geometry checks, and genuine regression tests. A few observations worth considering:

Correctness & Logic

FitAllNodesController — counter-based re-fit (NodesTab.tsx:274)

The counter approach is correct and deliberately documented (request is monotonically increasing). One subtle edge: lastHandled.current is initialized to 0, and the guard request === 0 prevents the initial render from doing anything. This means calling setFitAllRequest(n => n + 1) from 0 → 1 will run correctly. The pattern is solid.

FitAllNodesController single-node case (NodesTab.tsx:286–289)

map.setView(positions[0], Math.max(map.getZoom(), 14), { animate: true });

Math.max(map.getZoom(), 14) is a reasonable choice, though it has one edge case: if the user is already zoomed to level 18 on a single node and hits "fit all," this will keep them at 18 rather than zooming out to a more contextual view. This is probably acceptable UX (don't force-zoom-out), but worth noting for future decisions.

fitAllPositions dependency array (NodesTab.tsx:1570–1572)

// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on nodePositions like measurePoints below
[nodePositions, nodesWithPosition.map(n => n.nodeNum).join(',')],

The disable comment is accurate — this mirrors the established measurePoints pattern. The join(',') key is a fine structural fingerprint. Not a bug but a known trade-off (recomputes if nodes are removed and re-added in the same order with the same IDs, since the string would be identical).

CSS / Cascade

Safe-area regression fix (Sidebar.css:336, SourceNav.module.css:208)

The fix is correct. env(safe-area-inset-bottom, 0px) !important on .sidebar and padding-bottom: env(safe-area-inset-bottom, 0px) on .mobileBottomBar address the issue at both the container and component level. The existing comment in Sidebar.css explains the !important cascade ordering requirements clearly.

max-height: calc(100% - 50px) for .map-controls (nodes.css:2406)

This is the right geometric solution given the z-index constraint (997 vs 1000 for the attribution). The hardcoded 50px (16px top + ~34px for attribution) is documented in an inline comment — it would be worth confirming this accounts for the right breakpoints. The comment says "16px top offset + ~34px for the attribution strip and a little air," which is functional but not a named variable. If the top offset changes, the 50px becomes stale silently. Minor, but noting it.

mask-image scroll-fade implementation (SourceNav.module.css:239–256)

Excellent choice over ::after overlay — the reasoning is correctly documented (pseudo-elements inside scroll containers scroll with the content). The -webkit- prefix is appropriately paired with the unprefixed version.

One subtle point: applying mask-image to an element that already uses padding-bottom: env(safe-area-inset-bottom, 0px) could create a visual gap at the bottom of the fade on devices with a home indicator. The fade stops at the visible content edge, but safe area padding extends below it — this may or may not be visible depending on the background color. Not a blocker, but worth a hardware check.

Performance

ResizeObserver in SourceNav.tsx:127–129

const observer =
  typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null;
observer?.observe(el);

The typeof guard is appropriate for test environments (jsdom). The observer is correctly disconnected in the cleanup function. Passive scroll listener is also correctly marked. No concerns here.

fitAllPositions memo recalculation

The memo is keyed on nodePositions (a Map) plus a node-num string. Map objects in React memos: if nodePositions is a new Map instance on each render, the memo recomputes every render regardless of the string key. The eslint-disable comment suggests this is an accepted pattern already in the codebase, and mirrors measurePoints, so this is consistent — just noting it's not free.

Test Coverage

CSS-source tests (SourceNav.mobile.test.ts)

The test strategy of reading CSS source directly is well-reasoned and clearly documented — jsdom implements no layout/cascade, so stylesheet assertions are the correct tool for these contract tests. The lastIndexOf fix mentioned in the PR description (to avoid anchoring on the cross-reference comment) is a good catch and is documented inline.

mobileOverrideRule() helper in NodesTab.test.tsx:371–377

function mobileOverrideRule(selector: string): string {
  const block = nodesCss.slice(nodesCss.lastIndexOf('Map Controls (mobile override)'));
  ...
}

Using lastIndexOf is deliberate here for the same reason described in the PR notes. The test correctly validates that the geometric fix is present. The z-index: 997 assertion (expect(rule).toMatch(/z-index:\s*997/)) is particularly good — it pins the constraint that prevents a z-index solution, which documents the architectural decision.

Overflow cue tests (SourceNav.test.tsx:122–168)

The 1px slack logic in measure() at NodesTab.tsx:114–116 is tested via the 4 boundary cases (fits, trailing, mid-scroll, scrolled-to-end). These cover the relevant states. One unverified case: the scrollWidth > clientWidth + 1 gate determining whether the bar is "scrollable at all" — this is only implicitly tested. Since scrollWidth === clientWidth in the "fits" case (both set to 300), the scrollable flag being false drives both attributes to false, which is the correct path. This is fine.

Minor Observations

  1. touch-action: pan-x on the bottom bar (SourceNav.module.css:221) is correctly scoped to horizontal panning. However, pan-x alone will also suppress vertical scrolling on the bar, which should be fine since the bar is a horizontal scroll container. Correct choice.

  2. The PR description honestly states "Partial by nature" for the PWA gesture suppression — the OS-level home-indicator and app-switcher gestures cannot be fully suppressed by CSS. This transparency is appreciated.

  3. z-index: 900 for the mobile bottom bar (Sidebar.css:348) — down from 10001. The comment correctly explains the reasoning (shell furniture vs. overlay). Worth monitoring that future overlays (new modals, drawers) stay consistent with the documented z-index layer conventions.

Summary

The implementation is solid: the safe-area regression is correctly fixed, the geometric approach to the map-controls/attribution overlap is sound, and the zoom-to-fit counter pattern is clean and testable. The cascade-order discipline (post-base-rule media overrides) is consistently applied and documented. No blocking issues found.

Approved for merge with confidence. The one minor item to validate on real hardware: the mask-image bottom-edge behavior on a device with a home indicator (iPhone 14 Pro Max).


@Yeraze
Yeraze merged commit c77b94b into main Aug 2, 2026
15 checks passed
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.

[BUG] iOS PWA bottom nav: safe-area padding overridden, scroll fights home-indicator/app-switcher gesture, no scroll cue

1 participant