Skip to content

Commit 760a69e

Browse files
0800timclaude
andcommitted
fix(pwa): Install button shows steps instead of silently dismissing
The PWA install affordance had two failure modes that looked identical to the user (click → drawer closes → nothing installs): 1. `kind: "generic"` state hits when Chromium's beforeinstallprompt hasn't fired yet. The onClick handler called dismiss() with no fallback path, so a tap just hid the row for 30 days. 2. `kind: "chromium"` with a saved event but the saved event has already been consumed (Chrome only lets prompt() fire once per engagement window). prompt() throws, the catch ran dismiss(), same broken UX. Fix: - generic + ios states now expand to show browser-specific instructions inline ("Tap Share, then Add to Home Screen" / "Open the three-dot menu → Install app", etc.). The X button is still the only way to dismiss for 30 days. - chromium state's failure path also expands instructions so the user has a working route even when prompt() refuses. - UA-sniffed step lists for iOS Safari, Android Chrome, Edge, desktop Chrome, with a generic fallback for everything else. The native install dialog still triggers on Android Chrome / desktop Chrome / Edge when beforeinstallprompt has actually been captured and not yet consumed; the user just gets a useful fallback instead of an opaque close. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Tim Thomas <0800tim@gmail.com>
1 parent b437838 commit 760a69e

2 files changed

Lines changed: 97 additions & 7 deletions

File tree

apps/web/components/shell/InstallPrompt.tsx

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,52 @@ function isIosSafari(): boolean {
8686
return isIos && !isChromeOrFx;
8787
}
8888

89+
/** Browser-specific instruction copy shown when we can't trigger the
90+
* native install dialog. Falls back to a generic step list when we
91+
* can't sniff the UA. Tim 2026-05-23: clicking Install used to call
92+
* dismiss() and close the drawer with no install path; this state
93+
* now expands the row to show what to do. */
94+
function instructionSteps(state: InstallState): readonly string[] {
95+
if (state.kind === "ios") {
96+
return [
97+
"Tap the Share icon at the bottom of Safari.",
98+
"Scroll the share sheet and tap “Add to Home Screen”.",
99+
"Tap Add. Tournamental opens like a normal app from your home screen.",
100+
];
101+
}
102+
if (typeof window !== "undefined") {
103+
const ua = window.navigator.userAgent ?? "";
104+
if (/Android.*Chrome/i.test(ua)) {
105+
return [
106+
"Tap the three-dot menu at the top right of Chrome.",
107+
"Tap “Install app” (or “Add to Home screen”).",
108+
"Confirm to install. Tournamental launches from your home screen with no browser chrome.",
109+
];
110+
}
111+
if (/Edg\//i.test(ua)) {
112+
return [
113+
"Click the three-dot menu at the top right of Edge.",
114+
"Choose Apps → Install Tournamental.",
115+
"Confirm. Tournamental opens in its own window.",
116+
];
117+
}
118+
if (/Chrome/i.test(ua) && !/Mobile/i.test(ua)) {
119+
return [
120+
"Click the install icon in the address bar (small box with a downward arrow), or open the three-dot menu and choose Cast, save, and share → Install Tournamental.",
121+
"Confirm. Tournamental opens in its own window.",
122+
];
123+
}
124+
}
125+
return [
126+
"Open your browser’s main menu (usually three dots or a hamburger icon).",
127+
"Look for “Install app”, “Add to Home Screen”, or “Install Tournamental”.",
128+
"Confirm. Tournamental launches as a standalone app.",
129+
];
130+
}
131+
89132
export function InstallPrompt() {
90133
const [state, setState] = useState<InstallState>({ kind: "hidden" });
134+
const [showSteps, setShowSteps] = useState(false);
91135

92136
useEffect(() => {
93137
if (typeof window === "undefined") return undefined;
@@ -139,17 +183,28 @@ export function InstallPrompt() {
139183
try {
140184
await state.event.prompt();
141185
const choice = await state.event.userChoice;
142-
if (choice.outcome === "accepted" || choice.outcome === "dismissed") {
186+
if (choice.outcome === "accepted") {
143187
dismiss();
188+
return;
144189
}
190+
// outcome === "dismissed": Chrome will refuse to fire the
191+
// same prompt event again, so fall through to the instructions
192+
// hint instead of silently re-dismissing. The user clicked
193+
// Install for a reason; give them a path that still works.
194+
setShowSteps(true);
145195
} catch {
146-
dismiss();
196+
// event.prompt() can throw if the saved event has been
197+
// consumed already (the browser only lets prompt() fire once
198+
// per engagement). Show instructions in that case too.
199+
setShowSteps(true);
147200
}
148201
return;
149202
}
150-
// iOS + generic paths: no programmatic install API. Tapping the
151-
// line counts as "saw the hint" and dismisses for 30 days.
152-
dismiss();
203+
// iOS + generic paths: no programmatic install API; expand to
204+
// show browser-specific instructions instead of dismissing the
205+
// affordance (Tim 2026-05-23 — clicking Install used to close
206+
// the drawer without anything happening).
207+
setShowSteps((v) => !v);
153208
};
154209

155210
// Single-word visible label per Tim 2026-05-21 ("just say Install").
@@ -162,13 +217,19 @@ export function InstallPrompt() {
162217
? "Install Tournamental: open your browser menu, then Install app"
163218
: "Install Tournamental as an app";
164219

220+
const steps = showSteps ? instructionSteps(state) : null;
221+
165222
return (
166-
<div className="vt-drawer-install">
223+
<div
224+
className="vt-drawer-install"
225+
data-expanded={showSteps ? "1" : undefined}
226+
>
167227
<button
168228
type="button"
169229
className="vt-drawer-install-cta"
170230
onClick={onClick}
171231
aria-label={ariaLabel}
232+
aria-expanded={showSteps}
172233
>
173234
{/* Standard install glyph (downward arrow into tray) per
174235
* 2026-05-21 — the gold ball was misread as a generic logo
@@ -206,6 +267,13 @@ export function InstallPrompt() {
206267
>
207268
208269
</button>
270+
{steps && (
271+
<ol className="vt-drawer-install-steps" aria-live="polite">
272+
{steps.map((s, i) => (
273+
<li key={i}>{s}</li>
274+
))}
275+
</ol>
276+
)}
209277
</div>
210278
);
211279
}

apps/web/components/shell/shell.css

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -786,12 +786,34 @@
786786
* + bottom safe-area margin from the old foot position are dropped;
787787
* a leading hairline rule under the drawer header is enough
788788
* separation. The element stays a flex row so the dismiss X can
789-
* sit alongside the CTA. */
789+
* sit alongside the CTA; the optional instruction list (Tim
790+
* 2026-05-23) wraps onto a new row below when shown. */
790791
display: flex;
791792
align-items: stretch;
792793
gap: 4px;
793794
margin: 8px 16px 16px;
794795
padding: 0;
796+
flex-wrap: wrap;
797+
}
798+
799+
.vt-drawer-install-steps {
800+
flex-basis: 100%;
801+
margin: 8px 0 0;
802+
padding: 12px 14px 12px 32px;
803+
list-style: decimal;
804+
background: rgba(192, 138, 38, 0.08);
805+
border: 1px solid rgba(192, 138, 38, 0.32);
806+
border-radius: var(--vt-radius-md, 10px);
807+
color: var(--vt-fg, #f4f4f5);
808+
font-size: 13px;
809+
line-height: 1.5;
810+
}
811+
812+
.vt-drawer-install-steps li {
813+
margin: 0 0 6px;
814+
}
815+
.vt-drawer-install-steps li:last-child {
816+
margin-bottom: 0;
795817
}
796818

797819
.vt-drawer-install-cta {

0 commit comments

Comments
 (0)