Mobile adaptation and PWA install
Mobile adaptation and PWA install
How the SPA adapts to narrow viewports and installs as a PWA. The service
worker and runtime caching strategy are configured in apps/web/vite.config.ts
(vite-plugin-pwa, injectManifest) with the worker source at
apps/web/src/sw.ts and lifecycle toasts in
apps/web/src/app/providers/PwaLifecycle.tsx.
Breakpoint contract
One breakpoint separates the mobile and desktop React layouts: Tailwind md
(768px). CSS uses md: / max-md: variants; JS uses useIsMobile() from
@/shared/lib, whose MOBILE_MEDIA_QUERY is (max-width: 767.9px) so the
JS branch and CSS variants never disagree at exactly 768px. Do not introduce
additional React layout-switching breakpoints; finer CSS composition tuning is
allowed. In the planner's compact desktop range (md through 2xl), the
existing AgentDrawer becomes a right-side overlay instead of consuming a
third grid column. This preserves usable map/schedule width without introducing
a separate tablet shell. From md through 2xl, an open drawer takes over the
main workspace completely instead of leaving an unusable map sliver. At 2xl
and above it returns to the reserved three-pane composition.
Planner mobile shell
TravelPlannerPage renders one of two shells around the same state and the
same always-mounted mode panes:
- Desktop — the splitter layout: itinerary
AppSidebar, main panel with icon tabs,AgentDrawerside panel. At compact desktop widths the drawer overlays the main pane; wide screens reserve space beside it. - Mobile (
useIsMobile()) — a column shell, page-private underpages/travel-planner/ui/mobile/:MobilePlannerHeader— back/rename row plus the agent open button.MobileTabBar— bottom navigation with the four modes (Map, Schedule, Reservations, Budget),aria-currenton the active item, safe-area bottom padding.MobileItinerarySheet— a floating pill on the map mode that opens the day-grouped itinerary (the desktopSidebarcomponent) as a bottom sheet.MobileStopDetailSheet— the map's bottom detail surface; opens when a stop is selected on the map mode and reusesStopDetail.MobileAgentSheet— full-height agent chat wrappingAgentChat.
All four mode panes stay mounted (PlannerPane in TravelPlannerPage)
and inactive panes are hidden with visibility so switching modes preserves
scroll position and keeps the MapLibre canvas alive. This applies to both
shells.
On the mobile map mode the search control spans the top edge and the desktop day legend is hidden — the itinerary pill occupies that corner (see map.md).
Home mobile shell
The authenticated home content hub keeps the same three destinations as the desktop sidebar but presents them as a safe-area-aware bottom navigation: Trips, Today, and Travelogues. A compact sticky header holds the brand and account menu. Every surface reserves bottom padding so content and primary actions do not sit behind the navigation. The same card components collapse from the desktop content grid into one column; trip map thumbnails remain non-interactive so vertical swipes always scroll the page.
The travelogue composer uses DialogSheetPopup as a full-height editor below
md, with iOS-safe 16px text controls and safe-area padding in both the header
and footer. The reader hides its sticky section rail while preserving the
article and AI input as one continuous mobile column. The Today place card asks
for a city or region instead of requesting location permission again, remembers
the selection per user in localStorage, and renders current weather from the
shared weather API. The mobile stop-detail drawer exposes
the same “write in travelogue” action as desktop; it opens the full-height
composer with the stop title and trip association already filled. The frontend preview stores a
versioned local draft document per user in localStorage. It does not claim
offline sync, sharing, or a live AI response: the UI states that drafts and
excerpt-based answers remain local until the backend travelogue and media
adapters are implemented.
Drawer and responsive dialogs
@/shared/ui/drawer— Base UI Dialog styled as a mobile sheet.DrawerContent side="bottom"slides a rounded panel up from the bottom edge (drag-handle affordance, safe-area bottom padding);side="full"covers the viewport. Use it for mobile-only surfaces.DialogSheetViewport+DialogSheetPopup(@/shared/ui/dialog) — the responsive dialog policy: full-width bottom sheet belowmd, centered card above. Sizes:sm(440px),md(28rem),lg(2xl). All planner and trips dialogs use these; footers that touch the bottom edge pad withpb-[max(1.5rem,env(safe-area-inset-bottom))] md:pb-6.SettingsDialoggoes full-screen belowmdinstead of a sheet (it is a two-pane surface). Small confirm dialogs (AlertDialog) stay centered at every size.
Sheet heights that must track the visible area (including when the virtual
keyboard is open) use percentages of the Visual Viewport parent
(h-[70%], max-h-[min(92%,760px)]), not bare dvh — WebKit does not
shrink dvh for the keyboard.
Virtual keyboard and Visual Viewport
Bottom sheets and full-screen dialogs are position: fixed overlays. By
default mobile browsers use interactive-widget=resizes-visual: only the
Visual Viewport shrinks when the keyboard opens, so fixed overlays stay
anchored to the Layout Viewport and cover the focused input (common in the
PWA and WeChat WKWebView).
Root fix (not per-form scroll hacks):
interactive-widget=resizes-contenton the viewport meta inapps/web/index.html— Chromium / Firefox resize the Layout Viewport (and thereforedvh/ fixed overlays) with the keyboard (MDN, Chrome).installVisualViewportCssVars()(@/shared/lib) — mirrorswindow.visualViewportinto:rootCSS variables (--vv-top,--vv-left,--vv-width,--vv-height,--keyboard-inset). Mounted once fromAppProviders. Covers WebKit (iOS Safari / WeChat), which still ignoresinteractive-widget.- Overlay viewports (
DialogSheetViewport,DrawerContent,SettingsDialog) useVISUAL_VIEWPORT_FIXED_CLASSso they occupy the visible box.DialogPanel/DrawerPanelscroll the focused control into view after focus when the panel itself overflows.
Do not add wizard-specific scrollIntoView or hardcoded keyboard offsets
for new forms — reuse these primitives.
Safe areas
index.html sets viewport-fit=cover, so env(safe-area-inset-*) is
non-zero on notched devices in standalone mode. Pad any surface that touches
a screen edge on mobile: top bars use
pt-[max(<fallback>,env(safe-area-inset-top))], bottom bars and sheet
footers use the matching safe-area-inset-bottom form. The Drawer primitive
and the sheet dialog footers already do this.
Mobile permission onboarding
features/mobile-onboarding (MobileOnboarding, mounted from the app
providers) opens a bottom Drawer on first mobile visit (useIsMobile()),
walking through up to three steps — each asked at most once, with the
outcome persisted under the opentrip.mobile-onboarding.v1 localStorage
key:
- Install — offers to add OpenTrip to the home screen. On Chromium the
beforeinstallpromptevent is captured at module scope (model/install-prompt.ts) and replayed from the button viapromptInstall(), which awaits the browser dialog'suserChoice: the step is stored as accepted only when the user accepts that dialog (dismissing it stores a dismissal, keeping the settings re-offer alive). On iOS (no install API) the step shows share-menu instructions instead. Skipped in standalone display mode or when neither path applies. - Notifications — requests
Notificationpermission so toasts can reach the user as system push while the app is backgrounded (see the bridge below). Skipped when permission is already decided. - Location — triggers a high-accuracy
getCurrentPositioncall so the browser asks for precise location up front; the map'sGeolocateControlthen starts without an extra permission prompt. Skipped when the Permissions API reports the choice as already made.
The sheet opens after a short delay (ONBOARDING_OPEN_DELAY_MS) to give
beforeinstallprompt time to fire. Dismissing the sheet marks the
remaining steps as dismissed — the flow never re-nags.
A declined prompt is not a dead end: the same slice exports
PermissionSettings, a mobile-only "Device permissions" section rendered
in the settings preferences pane. It shows the live state of all three
asks (via the Permissions API change events and the deferred install
prompt) and re-offers the action where the browser still allows it; a
denied permission surfaces a pointer to the browser settings instead,
since the page cannot re-prompt.
Toast → system notification bridge
installSystemNotificationBridge() (@/shared/ui/toast, called once from
the app providers) decorates toastManager.add so that on mobile
(MOBILE_MEDIA_QUERY), with Notification permission granted and the page
hidden, toasts are mirrored as system notifications via the service
worker registration's showNotification (Android requires SW delivery;
new Notification is the fallback). A visible page keeps in-app toasts
only — a system banner would duplicate them. loading toasts and toasts
with non-string titles are never mirrored.
Install metadata and icons
index.htmlcarries thetheme-colormetas (light#fafbfd, dark#000000— the--backgroundtokens), theapple-mobile-web-app-*metas, tab favicons (favicon.ico,favicon-32x32.png), and theapple-touch-iconlink. iOS installers need the PNG apple-touch asset; browsers that still request/favicon.icoby default get a real icon file instead of the SPA HTML fallback.- The same document owns the canonical production URL plus Open Graph and
Twitter card metadata. Both surfaces use the committed 1200×600 image at
apps/web/public/og-image.png, published ashttps://opentrip.im/og-image.png. - The manifest (inline in
vite.config.ts) lists the PNG PWA icons;theme_color/background_colormatch the app background so the install splash blends into the UI. - Regenerate derived icons after replacing
app-icon-master.pngwithpnpm --filter @opentrip/web icons:generate(apps/web/scripts/generate-pwa-icons.mjs,sharp) and commit the output (favicon, apple-touch, and PWA PNGs).
Navigations and update safety
The service worker registers a NavigationRoute bound to the precached
index.html, so every SPA navigation is served from the precache (API
routes are denylisted). This keeps the HTML and the hashed assets it
references from the same build: without it, a navigation right after an
in-app update could pick up a stale HTML response from the browser or CDN
cache whose hashed assets the new deploy already purged — the page then
loads unstyled or not at all. It also makes offline navigation to any route
work from the precached shell.
Serving headers
apps/web/public/_headers (Cloudflare Pages) marks everything unhashed —
including HTML — as no-cache so a deploy is picked up promptly and stale
index.html can never pin purged assets; hashed /assets/* stay immutable
and /fonts/* get a medium-lived cache. See
../operations/cloudflare.md.
Conventions
- Interactive elements keep a ≥ 40×40px hit area (
h-10/size-10or larger); the bottom navigation items are full-column targets. - Hover-only affordances stay behind
@media (hover: hover) and (pointer: fine); sheet motion uses the shared duration/easing tokens and respectsprefers-reduced-motion. - Mobile-only copy lives in the locale namespaces like everything else
(e.g.
planner:nav.label); no literal strings.