diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 68dbd3f95..4f2fa7f61 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,6 +8,7 @@ import { setupQueries } from './hooks/queries.js' import { queryClient, persistOptions } from './queryClient.js' import { shellReload } from './lib/shellReloadState.js' import { beginEmbedBootstrap } from './lib/chatEmbedBootstrap.js' +import { startInstallPromptCapture } from './lib/installPrompt.js' // These flows are mutually exclusive. Keep setup, login, the full shell, and // the opaque embed out of one another's startup path; first boot should not @@ -38,6 +39,10 @@ const EMBED_ROUTE = isEmbedRoute() if (EMBED_ROUTE) { beginEphemeralAuth() beginEmbedBootstrap() +} else { + // Capture Chromium's one-shot install event before setup or sign-in can keep + // the first-use shell card from mounting. + startInstallPromptCapture() } // Validate a ?return= target: same-origin in-app path only. Rejects diff --git a/frontend/src/components/ChatView/__tests__/accessibilityHardening.test.js b/frontend/src/components/ChatView/__tests__/accessibilityHardening.test.js index 3d7cb838d..60485cdbb 100644 --- a/frontend/src/components/ChatView/__tests__/accessibilityHardening.test.js +++ b/frontend/src/components/ChatView/__tests__/accessibilityHardening.test.js @@ -45,6 +45,9 @@ test('first-use guidance is a labeled non-modal region with a dismiss action', ( assert.match(source, /role="region"/) assert.match(source, /aria-labelledby="wt-title"/) assert.match(source, /aria-label="Dismiss welcome"/) + assert.match(source, /aria-labelledby="wt-install-title"/) + assert.match(source, /aria-expanded=/) + assert.match(source, /role="status"/) assert.doesNotMatch(source, /aria-modal="true"/) }) diff --git a/frontend/src/components/Shell/Shell.css b/frontend/src/components/Shell/Shell.css index b7c0dde13..374697b95 100644 --- a/frontend/src/components/Shell/Shell.css +++ b/frontend/src/components/Shell/Shell.css @@ -424,11 +424,10 @@ /* The install affordance lives in the first-sign-in walkthrough now (see frontend/src/components/Walkthrough/WalkthroughOverlay.jsx). - beforeinstallprompt capture and the drawer banner that used to - live here are gone — the walkthrough's "Add to home screen" step - tells the user how to install for their platform exactly once, - then never appears again. Sub-app install lives in the standalone - card surfaced from drawer ⋮ on a mini-app. */ + App.jsx captures beforeinstallprompt early enough to survive account + setup; the walkthrough consumes it or shows platform-specific manual + guidance. The old drawer banner stays retired. Sub-app installation + lives in the standalone card surfaced from drawer ⋮ on a mini-app. */ /* Global offline indicator pill in the top bar. Shown only when the browser reports offline (useOnlineStatus). Keeps the user oriented diff --git a/frontend/src/components/Shell/__tests__/workspaceUi.test.js b/frontend/src/components/Shell/__tests__/workspaceUi.test.js index 2791d3535..ee0e4b916 100644 --- a/frontend/src/components/Shell/__tests__/workspaceUi.test.js +++ b/frontend/src/components/Shell/__tests__/workspaceUi.test.js @@ -122,6 +122,8 @@ test('the first-run walkthrough stays short and action-first', () => { assert.match(walkthrough, /Your Möbius is ready/) assert.match(walkthrough, /Connect an agent/) assert.match(walkthrough, /Open the App Store/) + assert.match(walkthrough, /Keep Möbius close/) + assert.match(walkthrough, /requestInstall/) assert.match(walkthrough, /I’ll explore/) assert.match(walkthrough, /mobius:walkthrough-completed/) }) diff --git a/frontend/src/components/Walkthrough/WalkthroughOverlay.css b/frontend/src/components/Walkthrough/WalkthroughOverlay.css index c9afbc033..81e326782 100644 --- a/frontend/src/components/Walkthrough/WalkthroughOverlay.css +++ b/frontend/src/components/Walkthrough/WalkthroughOverlay.css @@ -62,6 +62,7 @@ } .wt__close:focus-visible, +.wt__install-btn:focus-visible, .wt__path:focus-visible, .wt__dismiss:focus-visible { outline: 2px solid var(--accent, #8b6cf7); @@ -116,6 +117,92 @@ line-height: 1.55; } +.wt__install { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + gap: 4px 10px; + align-items: center; + margin-top: 18px; + padding: 12px; + background: color-mix(in srgb, var(--accent, #8b6cf7) 8%, var(--surface2, #1a1f28)); + border: 1px solid color-mix(in srgb, var(--accent, #8b6cf7) 22%, var(--border, #252b36)); + border-radius: 12px; +} + +.wt__install-icon { + display: grid; + place-items: center; + width: 36px; + height: 36px; + color: var(--accent, #a78bfa); + background: color-mix(in srgb, var(--accent, #8b6cf7) 15%, transparent); + border-radius: 10px; +} + +.wt__install-copy { + min-width: 0; +} + +.wt__install-copy h3 { + margin: 0; + color: var(--text, #e4e4e7); + font-size: 14px; + font-weight: 680; + line-height: 1.25; +} + +.wt__install-copy p { + margin: 3px 0 0; + color: var(--muted, #a1a1aa); + font-size: 12px; + line-height: 1.35; +} + +.wt__install-btn { + min-height: 34px; + padding: 6px 10px; + color: var(--accent, #a78bfa); + background: color-mix(in srgb, var(--accent, #8b6cf7) 11%, transparent); + border: 0; + border-radius: 9px; + font: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.wt__install-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent, #8b6cf7) 18%, transparent); +} + +.wt__install-btn:disabled { + opacity: 0.64; + cursor: wait; +} + +.wt__install-help, +.wt__install-feedback { + grid-column: 2 / -1; + margin: 7px 0 0; + font-size: 12px; + line-height: 1.45; +} + +.wt__install-help { + display: grid; + gap: 2px; + color: var(--muted, #a1a1aa); +} + +.wt__install-help strong { + color: var(--text, #e4e4e7); + font-weight: 650; +} + +.wt__install-feedback { + color: var(--muted, #a1a1aa); +} + .wt__paths { display: grid; gap: 1px; @@ -195,6 +282,11 @@ .wt__path small { white-space: normal; } + + .wt__install { + grid-template-columns: 34px minmax(0, 1fr) auto; + padding: 10px; + } } @media (prefers-reduced-motion: reduce) { diff --git a/frontend/src/components/Walkthrough/WalkthroughOverlay.jsx b/frontend/src/components/Walkthrough/WalkthroughOverlay.jsx index c88b8a7dd..bcdb4f672 100644 --- a/frontend/src/components/Walkthrough/WalkthroughOverlay.jsx +++ b/frontend/src/components/Walkthrough/WalkthroughOverlay.jsx @@ -1,12 +1,32 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState, useSyncExternalStore } from 'react' import { useQueryClient } from '@tanstack/react-query' +import { Download } from 'lucide-react' import { api } from '../../api/client.js' import { ownerQueries } from '../../hooks/queries.js' +import { + getInstallPromptSnapshot, + requestInstall, + subscribeInstallPrompt, +} from '../../lib/installPrompt.js' +import { + detectInstallPlatform, + installCopyForPlatform, +} from '../../utils/installPlatform.js' import './WalkthroughOverlay.css' export default function WalkthroughOverlay({ onDone, onOpenSettings, onExploreApps }) { const queryClient = useQueryClient() const closingRef = useRef(false) + const [platform] = useState(() => detectInstallPlatform()) + const [installCopy] = useState(() => installCopyForPlatform(platform)) + const [showInstallHelp, setShowInstallHelp] = useState(false) + const [installBusy, setInstallBusy] = useState(false) + const [installFeedback, setInstallFeedback] = useState('') + const installState = useSyncExternalStore( + subscribeInstallPrompt, + getInstallPromptSnapshot, + getInstallPromptSnapshot, + ) function finish() { if (closingRef.current) return @@ -25,16 +45,41 @@ export default function WalkthroughOverlay({ onDone, onOpenSettings, onExploreAp action?.() } + async function handleInstall() { + setInstallFeedback('') + if (installState !== 'ready') { + setShowInstallHelp((visible) => !visible) + return + } + + setInstallBusy(true) + const result = await requestInstall() + setInstallBusy(false) + if (result.outcome === 'accepted') { + finish() + return + } + + setShowInstallHelp(true) + setInstallFeedback( + result.outcome === 'dismissed' + ? 'Not installed. You can use the browser menu whenever you’re ready.' + : 'The browser prompt wasn’t available. Use the steps below instead.', + ) + } + useEffect(() => { - const standalone = - (typeof window !== 'undefined' && - window.matchMedia && - window.matchMedia('(display-mode: standalone)').matches) || - (typeof navigator !== 'undefined' && navigator.standalone === true) - if (standalone) finish() - // finish is guarded and this should run only once on mount. + if (installState === 'installed') finish() + // The event can arrive after mount; finish is guarded across rerenders. // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [installState]) + + const nativeInstallReady = installState === 'ready' + const installButtonLabel = installBusy + ? 'Opening…' + : (nativeInstallReady + ? 'Install' + : (showInstallHelp ? 'Hide' : installCopy.ctaLabel)) return (