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

Merged
dominik.polakovics merged 2 commits from afk/142 into main 2026-07-11 20:21:29 +02:00

Implements the settled #142 design: on a phone browser that is not running as the installed PWA, a bottom sheet offers installing lab to the Home Screen.

What ships

  • web/src/lib/install.ts — headless logic as Solid signals + derivations (lib/ tested-seam convention). beforeinstallprompt is captured at module init with preventDefault() (suppresses Chrome's mini-infobar) and stashed; appinstalled drops the stash and closes the sheet + Settings row. Eligibility truth table: not standalone (display-mode + legacy navigator.standalone), coarse pointer, not dismissed, variant available. iOS detection unmasks Mac-UA iPadOS via maxTouchPoints. Android is eligible only with the event in hand — if it never fires, nothing shows (no dead Install button, no fallback instructions). promptInstall() treats the Chromium event as one-shot: fire, drop, close.
  • web/src/components/InstallSheet.tsx — the repo's first modal primitive: scrim + card sliding up from the bottom edge (thumb-reachable; on iOS adjacent to Safari's bottom-toolbar Share button the instructions point at). Android variant: Install + Not now. iOS variant: the two illustrated Share → Add to Home Screen steps (Lucide share / square-plus glyphs vendored into Icon.tsx) + Not now. Scrim and card are siblings so the dismiss handler lives on the scrim alone. role="dialog", aria-modal, labelled heading; reduced-motion disables the slide-up.
  • Wiring — AppShell mounts the sheet post-auth (never on /login or /setup); Settings gains an Install app re-entry card that clears the permanent dismissal and reopens the same sheet. Row visibility exactly per issue: hidden standalone, iOS always otherwise, Android only while an event is in hand.
  • Dismissal — Not now / scrim tap persists lab.install-dismissed; the Settings row is the sole re-entry.

Verification

  • npx vitest run: 40 files, 657 tests green — 25 new lib tests (full eligibility truth table, capture/dismiss/appinstalled/promptInstall, matchMedia-absent degradation), 8 InstallSheet component tests (both variants, scrim-vs-card, a11y), 2 Settings integration tests driving the real singleton end to end (event lands → row appears → sheet opens → Not now persists the flag).
  • tsc --noEmit, eslint ., prettier --check ., vite build all clean; Go side (go build ./... && go test ./..., CGO_ENABLED=0) untouched and green.
  • Note (jsdom finding baked into the design): jsdom 29 ships no window.matchMedia, so the lib treats a missing implementation as "no match" — the singleton stays inert and throw-free under every other jsdom suite that mounts AppShell.
  • No Playwright e2e, per the issue: real browsers cannot fake beforeinstallprompt or display-mode: standalone without testing a mock.

Closes #142

🤖 Generated with Claude Code

https://claude.ai/code/session_0111erhhwzsJneW6D72kLh8F

Implements the settled #142 design: on a phone browser that is not running as the installed PWA, a bottom sheet offers installing lab to the Home Screen. ## What ships - **`web/src/lib/install.ts`** — headless logic as Solid signals + derivations (lib/ tested-seam convention). `beforeinstallprompt` is captured at module init with `preventDefault()` (suppresses Chrome's mini-infobar) and stashed; `appinstalled` drops the stash and closes the sheet + Settings row. Eligibility truth table: not standalone (`display-mode` + legacy `navigator.standalone`), coarse pointer, not dismissed, variant available. iOS detection unmasks Mac-UA iPadOS via `maxTouchPoints`. Android is eligible only with the event in hand — if it never fires, nothing shows (no dead Install button, no fallback instructions). `promptInstall()` treats the Chromium event as one-shot: fire, drop, close. - **`web/src/components/InstallSheet.tsx`** — the repo's first modal primitive: scrim + card sliding up from the bottom edge (thumb-reachable; on iOS adjacent to Safari's bottom-toolbar Share button the instructions point at). Android variant: Install + Not now. iOS variant: the two illustrated Share → Add to Home Screen steps (Lucide `share` / `square-plus` glyphs vendored into Icon.tsx) + Not now. Scrim and card are siblings so the dismiss handler lives on the scrim alone. `role="dialog"`, `aria-modal`, labelled heading; reduced-motion disables the slide-up. - **Wiring** — AppShell mounts the sheet post-auth (never on /login or /setup); Settings gains an **Install app** re-entry card that clears the permanent dismissal and reopens the same sheet. Row visibility exactly per issue: hidden standalone, iOS always otherwise, Android only while an event is in hand. - **Dismissal** — Not now / scrim tap persists `lab.install-dismissed`; the Settings row is the sole re-entry. ## Verification - `npx vitest run`: 40 files, 657 tests green — 25 new lib tests (full eligibility truth table, capture/dismiss/appinstalled/promptInstall, matchMedia-absent degradation), 8 InstallSheet component tests (both variants, scrim-vs-card, a11y), 2 Settings integration tests driving the real singleton end to end (event lands → row appears → sheet opens → Not now persists the flag). - `tsc --noEmit`, `eslint .`, `prettier --check .`, `vite build` all clean; Go side (`go build ./... && go test ./...`, CGO_ENABLED=0) untouched and green. - Note (jsdom finding baked into the design): jsdom 29 ships **no** `window.matchMedia`, so the lib treats a missing implementation as "no match" — the singleton stays inert and throw-free under every other jsdom suite that mounts AppShell. - No Playwright e2e, per the issue: real browsers cannot fake `beforeinstallprompt` or `display-mode: standalone` without testing a mock. Closes #142 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0111erhhwzsJneW6D72kLh8F
feat(web): PWA install bottom sheet on phone when not running installed
All checks were successful
ci / native (pull_request) Successful in 5m10s
6c205c21f4
Show a bottom-sheet install prompt on coarse-pointer browsers that are not
running as the installed PWA, per the settled #142 design:

- lib/install.ts: headless tested seam — captures beforeinstallprompt at
  module init (preventDefault suppresses Chrome's mini-infobar), stashes it
  in a signal, and derives the auto-show truth table (not standalone, coarse
  pointer, not dismissed, variant available). iOS (incl. Mac-UA iPadOS) is
  eligible with no event; Android only once the event is in hand — no dead
  Install button. appinstalled drops the stash and closes everything.
- components/InstallSheet.tsx: first modal primitive — scrim + slide-up
  card. Android renders Install (fires the stashed prompt, one-shot) and
  Not now; iOS renders the two Share -> Add to Home Screen steps with
  vendored Lucide glyphs. Any dismissal persists lab.install-dismissed.
- AppShell mounts the sheet post-auth (never on /login); Settings gains an
  "Install app" re-entry row that clears the dismissal and reopens the
  sheet (hidden standalone; iOS always otherwise; Android event-gated).

Vitest covers the eligibility truth table (jsdom ships no matchMedia — its
absence reads as matches=false), both sheet variants, and the Settings row
wiring end to end. No Playwright: real browsers cannot fake
beforeinstallprompt or display-mode without testing a mock.

Closes #142

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111erhhwzsJneW6D72kLh8F
Merge remote-tracking branch 'origin/main' into afk/142
All checks were successful
ci / native (pull_request) Successful in 5m16s
5255864201
# Conflicts:
#	web/src/components/AppShell.tsx
Sign in to join this conversation.
No reviewers
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!144
No description provided.