feat(web): PWA install bottom sheet on phone when not running installed #142

Closed
opened 2026-07-11 14:49:20 +02:00 by dominik.polakovics · 1 comment

On a phone browser that is not running as an installed PWA, show a bottom-sheet dialog offering to install the app to the Home Screen. Designed in a grill session 2026-07-11; every decision below is settled.

Behavior

Eligibility — the sheet shows only when ALL hold:

  • Not standalone: matchMedia('(display-mode: standalone)') is false AND navigator.standalone (iOS legacy) is falsy.
  • Coarse pointer: matchMedia('(pointer: coarse)') — phones and tablets, never desktop. No viewport-width or UA gate. (iPadOS reports a Mac UA but coarse pointer, so it correctly qualifies.)
  • Not previously dismissed (localStorage flag).
  • Authenticated app shell has mounted — never on the login page. No artificial delay beyond that.
  • Platform variant available (next section).

Two variants, adaptive:

  • Android/Chromium: capture beforeinstallprompt at module init with preventDefault() (this also suppresses Chrome's mini-infobar so only our sheet appears) and stash the event in a signal. The sheet renders an Install button that calls .prompt() on the stashed event, plus Not now. Reactive timing: the sheet appears whenever the event arrives, even seconds after shell mount; if the event never fires (already installed, unsupported browser), Android shows nothing — no dead Install button, no fallback instructions.
  • iOS: no install API exists, so the sheet shows two illustrated instruction steps — "1. Tap Share in the toolbar. 2. Choose Add to Home Screen" — with inline SVG glyphs of the actual iOS Share and Add-to-Home-Screen icons. Assume Safari; no Safari-vs-Chrome-vs-Firefox iOS sub-detection (operator population of one, uses Safari).

Dismissal: "Not now" (or scrim tap / close) sets a localStorage flag — the sheet never auto-shows again in that browser.

Settings re-entry: an Install app row in Settings reopens the same sheet on demand and clears the dismissed flag. Row visibility: hidden when running standalone; on iOS always shown otherwise; on Android shown only while a captured beforeinstallprompt event is in hand.

appinstalled: on this event, close the sheet, drop the stashed event, and let the Settings row disappear.

Form

Bottom sheet: card slides up from the bottom edge with a scrim behind it — native idiom for phone install prompts, thumb-reachable, and on iOS it sits visually adjacent to Safari's bottom-toolbar Share button that the instructions point at. This is the repo's first modal primitive (only Toast exists today); keep it self-contained in the component.

Code shape

  • web/src/lib/install.ts — headless logic: event capture, standalone/coarse-pointer/iOS detection, dismissed flag, appinstalled cleanup; exposed as Solid signals plus an eligible() derivation. Follows the existing lib/ tested-logic convention.
  • web/src/components/InstallSheet.tsx — scrim + card, both variants.
  • Mounted once in AppShell.tsx (post-auth); Settings.tsx gets the Install app row flipping the same open-signal.

Tests

Vitest unit tests for the lib/install.ts eligibility truth table (standalone / pointer / dismissed / event / iOS) with mocked matchMedia, plus component tests for both sheet variants (jsdom, matching sibling .test.ts(x) convention). No Playwright e2e — real browsers can't fake beforeinstallprompt or display-mode: standalone without testing a mock.

Out of scope

  • Desktop install promotion (coarse-pointer gate excludes it by design).
  • Android non-Chromium fallback instructions (Samsung Internet, Firefox Android show nothing).
  • Firefox-iOS/webview suppression — instructions are merely unfollowable there, not harmful.
  • Any snooze/re-prompt cadence — dismissal is permanent; the Settings row is the sole re-entry.
On a phone browser that is not running as an installed PWA, show a bottom-sheet dialog offering to install the app to the Home Screen. Designed in a grill session 2026-07-11; every decision below is settled. ## Behavior **Eligibility** — the sheet shows only when ALL hold: - Not standalone: `matchMedia('(display-mode: standalone)')` is false AND `navigator.standalone` (iOS legacy) is falsy. - Coarse pointer: `matchMedia('(pointer: coarse)')` — phones and tablets, never desktop. No viewport-width or UA gate. (iPadOS reports a Mac UA but coarse pointer, so it correctly qualifies.) - Not previously dismissed (localStorage flag). - Authenticated app shell has mounted — never on the login page. No artificial delay beyond that. - Platform variant available (next section). **Two variants, adaptive:** - **Android/Chromium**: capture `beforeinstallprompt` at module init with `preventDefault()` (this also suppresses Chrome's mini-infobar so only our sheet appears) and stash the event in a signal. The sheet renders an **Install** button that calls `.prompt()` on the stashed event, plus **Not now**. Reactive timing: the sheet appears whenever the event arrives, even seconds after shell mount; if the event never fires (already installed, unsupported browser), Android shows nothing — no dead Install button, no fallback instructions. - **iOS**: no install API exists, so the sheet shows two illustrated instruction steps — "1. Tap Share in the toolbar. 2. Choose Add to Home Screen" — with inline SVG glyphs of the actual iOS Share and Add-to-Home-Screen icons. Assume Safari; no Safari-vs-Chrome-vs-Firefox iOS sub-detection (operator population of one, uses Safari). **Dismissal**: "Not now" (or scrim tap / close) sets a localStorage flag — the sheet never auto-shows again in that browser. **Settings re-entry**: an **Install app** row in Settings reopens the same sheet on demand and clears the dismissed flag. Row visibility: hidden when running standalone; on iOS always shown otherwise; on Android shown only while a captured `beforeinstallprompt` event is in hand. **`appinstalled`**: on this event, close the sheet, drop the stashed event, and let the Settings row disappear. ## Form Bottom sheet: card slides up from the bottom edge with a scrim behind it — native idiom for phone install prompts, thumb-reachable, and on iOS it sits visually adjacent to Safari's bottom-toolbar Share button that the instructions point at. This is the repo's first modal primitive (only `Toast` exists today); keep it self-contained in the component. ## Code shape - `web/src/lib/install.ts` — headless logic: event capture, standalone/coarse-pointer/iOS detection, dismissed flag, `appinstalled` cleanup; exposed as Solid signals plus an `eligible()` derivation. Follows the existing `lib/` tested-logic convention. - `web/src/components/InstallSheet.tsx` — scrim + card, both variants. - Mounted once in `AppShell.tsx` (post-auth); `Settings.tsx` gets the Install app row flipping the same open-signal. ## Tests Vitest unit tests for the `lib/install.ts` eligibility truth table (standalone / pointer / dismissed / event / iOS) with mocked `matchMedia`, plus component tests for both sheet variants (jsdom, matching sibling `.test.ts(x)` convention). **No Playwright e2e** — real browsers can't fake `beforeinstallprompt` or `display-mode: standalone` without testing a mock. ## Out of scope - Desktop install promotion (coarse-pointer gate excludes it by design). - Android non-Chromium fallback instructions (Samsung Internet, Firefox Android show nothing). - Firefox-iOS/webview suppression — instructions are merely unfollowable there, not harmful. - Any snooze/re-prompt cadence — dismissal is permanent; the Settings row is the sole re-entry.
Author
Owner

This was generated by AI while landing a PR.

Landing audit — PR #144 (afk/142) → PASS (pending merge)

  • CI: ci / native green (success, 5m10s) on the pre-merge head.
  • Convention: title feat(web): … is Conventional Commits ✓.
  • AFK contract: body carries Closes #142 ✓.
  • Diff review: 9 files (+954/−1), all under web/. Adds lib/install.ts (headless eligibility signals), components/InstallSheet.tsx (modal primitive), wiring in AppShell/Settings, plus base.css. Matches the settled #142 design; no divergence from intent.
  • Merge conflict: base had advanced (drawer-drag PR #143). One conflict, in AppShell.tsx imports — both branches added a sibling import at the same line. Auto-resolved as a union in alphabetical-by-path order (drawerGestureinstallliveResource); deterministic and behaviour-preserving, no semantic choice. base.css auto-merged. The <InstallSheet> element merged cleanly into the rewritten return.
  • Re-verification of the merge commit (no gate had seen it): tsc --noEmit clean, vitest run 678/678 green, eslint clean, prettier --check clean, vite build clean.

Resolution pushed to afk/142. Awaiting free-text merge go-ahead.

> *This was generated by AI while landing a PR.* **Landing audit — PR #144 (`afk/142`) → PASS (pending merge)** - **CI**: `ci / native` green (success, 5m10s) on the pre-merge head. - **Convention**: title `feat(web): …` is Conventional Commits ✓. - **AFK contract**: body carries `Closes #142` ✓. - **Diff review**: 9 files (+954/−1), all under `web/`. Adds `lib/install.ts` (headless eligibility signals), `components/InstallSheet.tsx` (modal primitive), wiring in AppShell/Settings, plus base.css. Matches the settled #142 design; no divergence from intent. - **Merge conflict**: base had advanced (drawer-drag PR #143). One conflict, in `AppShell.tsx` imports — both branches added a sibling import at the same line. Auto-resolved as a union in alphabetical-by-path order (`drawerGesture` → `install` → `liveResource`); deterministic and behaviour-preserving, no semantic choice. `base.css` auto-merged. The `<InstallSheet>` element merged cleanly into the rewritten return. - **Re-verification of the merge commit** (no gate had seen it): `tsc --noEmit` clean, `vitest run` **678/678 green**, `eslint` clean, `prettier --check` clean, `vite build` clean. Resolution pushed to `afk/142`. Awaiting free-text merge go-ahead.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab#142
No description provided.