Skip to content

Commit 95cd69d

Browse files
alichherawallaUserclaude
authored
feat: pro-showcase onboarding, accurate Pro copy, mobile cross-sell (#27)
* feat: pro-showcase onboarding, accurate Pro copy, mobile cross-sell Onboarding now leads with the Pro capability layer as a dense grid (Replay, Meetings, To-dos, Memory, Entities, Day, Vault, Voice), each card blending what it does with the payoff ("<mechanism>, so <outcome>"), proof-first and on-brand (no hype, no competitor names). Fixes copy that no longer matched reality: Pro is live and paid now, not "coming July 2026 / free early access". Corrected onboarding, the Settings Pro badge, the seeded FAQ, and dropped the unused PRO_LAUNCH / PRO_EARLY_ACCESS_URL constants. The Day card no longer claims to read your calendar unconditionally - it's framed as the calendars you connect, since that depends on an MCP connector being enabled. Adds a reciprocal link to Off Grid AI Mobile (mobile already links back to desktop): a "Mobile app" item in the sidebar footer and a "Get Off Grid AI Mobile" row on the Pro upgrade screen, both opening getoffgridai.co/mobile (which carries both App Store and Google Play). New constants/links.ts holds the URL + a shared openExternal helper. All core cross-sell, no pro logic. E2E: guard that the onboarding Pro capability grid renders. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(e2e): Provit capture screenshots for PR evidence Onboarding Pro step (badge + 4-step dots) and the app shell showing the sidebar 'Mobile app' link. Captured via the Provit journey harness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(e2e): add Provit flow GIF for PR evidence Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(e2e): drop stills, keep the flow GIF for PR evidence Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: User <user@Users-MacBook-Pro-174.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d711d78 commit 95cd69d

9 files changed

Lines changed: 134 additions & 35 deletions

File tree

e2e/screenshots/provit-flow.gif

3.15 MB
Loading

e2e/smoke.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@ test('shows onboarding on a fresh install', async () => {
5252
await expect(page.getByRole('button', { name: /Continue|Start using Off Grid/i })).toBeVisible();
5353
});
5454

55+
test('onboarding surfaces the Pro capability grid', async () => {
56+
// Advance until the Pro step renders its capability cards, then assert a few
57+
// capabilities are shown by name (Replay, Meetings, Vault). Regression guard
58+
// for the onboarding redesign that showcases the Pro layer.
59+
const btn = page.getByRole('button', { name: /Continue|Start using Off Grid/i });
60+
for (let i = 0; i < 6; i++) {
61+
if (await page.getByText('Meetings').isVisible().catch(() => false)) break;
62+
if (!(await btn.isVisible().catch(() => false))) break;
63+
await btn.click();
64+
await page.waitForTimeout(400);
65+
}
66+
await expect(page.getByText('Replay')).toBeVisible();
67+
await expect(page.getByText('Meetings')).toBeVisible();
68+
await expect(page.getByText('Vault')).toBeVisible();
69+
await page.screenshot({ path: 'e2e/screenshots/onboarding-pro-grid.png' });
70+
});
71+
5572
test('completes onboarding and lands in the app shell', async () => {
5673
// Click through every onboarding step (Continue × N, then "Start using Off Grid").
5774
for (let i = 0; i < 6; i++) {

src/main/dev-seed.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ async function seedKnowledge(): Promise<void> {
9797
const files: { name: string; write: () => void }[] = [
9898
{ name: 'offgrid-overview.md', write: () => fs.writeFileSync(path.join(dir, 'offgrid-overview.md'), MD) },
9999
{ name: 'offgrid-faq.txt', write: () => fs.writeFileSync(path.join(dir, 'offgrid-faq.txt'),
100-
'Off Grid AI — FAQ\n\nQ: Does anything leave my device?\nA: No. All inference runs locally; no cloud, no accounts, no API keys.\n\nQ: What can it run?\nA: Open models for text, vision, image, voice and speech, via one OpenAI-compatible gateway on 127.0.0.1:7878.\n\nQ: What is Pro?\nA: The sees/remembers/acts layer — capture, unified search, a proactive secretary — launching July 2026.\n') },
100+
'Off Grid AI — FAQ\n\nQ: Does anything leave my device?\nA: No. All inference runs locally; no cloud, no accounts, no API keys.\n\nQ: What can it run?\nA: Open models for text, vision, image, voice and speech, via one OpenAI-compatible gateway on 127.0.0.1:7878.\n\nQ: What is Pro?\nA: The sees/remembers/acts layer — capture, unified search, a proactive secretary — live now, $49/year or $69 once.\n') },
101101
{ name: 'offgrid-onepager.pdf', write: () => fs.writeFileSync(path.join(dir, 'offgrid-onepager.pdf'), tinyPdf('Off Grid AI - private, on-device AI. Run open models locally. No cloud.')) },
102102
];
103103
for (const f of files) {

src/renderer/src/App.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,11 @@ import {
4141
IconLoader2,
4242
IconArrowLeft,
4343
IconArrowRight,
44-
IconActivityHeartbeat
44+
IconActivityHeartbeat,
45+
IconDeviceMobile,
46+
IconExternalLink
4547
} from '@tabler/icons-react';
48+
import { OFF_GRID_MOBILE_URL, openExternal } from './constants/links';
4649
import { cn } from './lib/utils';
4750

4851
type ViewMode = 'dashboard' | 'day' | 'replay' | 'reflect' | 'actions' | 'connectors' | 'meetings' | 'chats' | 'memories' | 'entities' | 'graph' | 'memory-chat' | 'models' | 'gateway' | 'projects' | 'notifications' | 'settings' | 'search' | 'clipboard' | 'voice' | 'vault';
@@ -680,6 +683,21 @@ function AppContent() {
680683
<ModelStatusDot open={sidebarOpen} onClick={() => (sidebarOpen ? setViewMode('settings') : setSidebarOpen(true))} />
681684
<NavThemeToggle expanded={sidebarOpen} />
682685
{bottomNav.map(renderNavItem)}
686+
{/* Cross-sell to the companion phone app — opens the /mobile page
687+
(App Store + Google Play). Mirrors mobile's link back to desktop. */}
688+
<button
689+
onClick={() => openExternal(OFF_GRID_MOBILE_URL)}
690+
title={!sidebarOpen ? 'Get the mobile app' : undefined}
691+
className={cn(
692+
'group/nav relative flex items-center gap-3 rounded-lg py-2 text-sm transition-colors',
693+
sidebarOpen ? 'px-3' : 'justify-center px-0',
694+
'text-neutral-500 hover:bg-neutral-500/10 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white'
695+
)}
696+
>
697+
<IconDeviceMobile className="h-5 w-5 shrink-0" />
698+
{sidebarOpen && <span className="flex-1 text-left whitespace-pre">Mobile app</span>}
699+
{sidebarOpen && <IconExternalLink className="h-3.5 w-3.5 shrink-0 text-neutral-400/60" />}
700+
</button>
683701
</div>
684702
</SidebarBody>
685703
</Sidebar>

src/renderer/src/components/Onboarding.tsx

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { motion, AnimatePresence, stagger, useAnimate } from 'motion/react';
33
import { LampContainer } from './ui/lamp';
44
import { OrbitingCircles } from './ui/orbiting-circles';
55
import { GridBackdrop } from './ui/grid-backdrop';
6-
import { BorderBeam } from './ui/border-beam';
76
import { cn } from '@renderer/lib/utils';
87
import logo from '@/assets/logo.png';
98
import {
@@ -15,9 +14,13 @@ import {
1514
Microphone,
1615
SpeakerHigh,
1716
FolderOpen,
18-
Cpu,
19-
LockKey,
20-
Sparkle,
17+
CalendarBlank,
18+
CheckSquare,
19+
Rewind,
20+
MagnifyingGlass,
21+
Graph,
22+
ShieldCheck,
23+
Waveform,
2124
} from '@phosphor-icons/react';
2225

2326
// Word-by-word blur-in, matching the brand's terminal feel.
@@ -65,7 +68,7 @@ interface OnboardingProps {
6568
onComplete: () => void;
6669
}
6770

68-
const steps = [{ id: 'welcome' }, { id: 'capabilities' }, { id: 'private' }];
71+
const steps = [{ id: 'welcome' }, { id: 'capabilities' }, { id: 'pro' }, { id: 'private' }];
6972

7073
const ORBIT = [
7174
{ icon: ChatCircle, label: 'Chat' },
@@ -76,6 +79,18 @@ const ORBIT = [
7679
{ icon: FolderOpen, label: 'Projects' },
7780
];
7881

82+
// The Pro layer — every capability described by what it does, on-device.
83+
const PRO_GRID = [
84+
{ icon: Rewind, label: 'Replay', line: 'Rewinds your screen history, so the doc or number you saw last week is a scrub away, not a hunt.' },
85+
{ icon: Microphone, label: 'Meetings', line: 'Records and transcribes your calls on-device, so you walk out with the decisions and to-dos already written.' },
86+
{ icon: CheckSquare, label: 'To-dos', line: 'Pulls the commitments out of your day and queues the next step, so nothing you promised quietly slips.' },
87+
{ icon: MagnifyingGlass, label: 'Memory', line: 'One search across everything you have seen, said, and saved, so you never lose a thing twice.' },
88+
{ icon: Graph, label: 'Entities', line: 'Builds a record of every person and project on its own, so you walk into any call knowing where you left off.' },
89+
{ icon: CalendarBlank, label: 'Day', line: 'Lays out your day from your work and the calendars you connect, so you start oriented instead of scrambling.' },
90+
{ icon: ShieldCheck, label: 'Vault', line: 'Encrypts passwords, keys, and secret files with a key that never leaves this Mac, so they stay yours alone.' },
91+
{ icon: Waveform, label: 'Voice', line: 'Hold Option+Space and talk - transcribed locally and pasted at your cursor, so you type with your voice anywhere.' },
92+
];
93+
7994
export function Onboarding({ onComplete }: OnboardingProps) {
8095
const [currentStep, setCurrentStep] = useState(0);
8196

@@ -159,39 +174,56 @@ export function Onboarding({ onComplete }: OnboardingProps) {
159174
</motion.div>
160175
)}
161176

162-
{/* Step 2 — Private + grows with you */}
177+
{/* Step 2 — The Pro layer: it starts remembering */}
163178
{currentStep === 2 && (
164179
<motion.div key="step-2" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="relative flex h-full w-full flex-col items-center justify-center bg-neutral-950 px-6">
165180
<GridBackdrop className="opacity-70" />
166-
<div className="relative z-10 mx-auto max-w-4xl">
167-
<div className="mb-14 text-center">
168-
<TextGenerate words="Yours, and only yours." className="text-3xl font-semibold tracking-tight text-white md:text-4xl" delay={0} />
181+
<div className="relative z-10 mx-auto max-w-5xl">
182+
<div className="mb-2 text-center">
183+
<span className="rounded-full border border-green-500/30 bg-green-500/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-green-400">Off Grid Pro · live now</span>
184+
</div>
185+
<div className="mb-3 text-center">
186+
<TextGenerate words="Then it starts remembering." className="text-3xl font-semibold tracking-tight text-white md:text-4xl" delay={0} />
169187
</div>
188+
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.5 }} className="mx-auto mb-10 max-w-2xl text-center text-sm text-neutral-400">
189+
The free app runs models. Pro adds the always-on layer: turn on capture and Off Grid keeps a private record of what you see and do, then acts on it with your approval. Every one runs on-device. Nothing is uploaded.
190+
</motion.p>
170191

171-
<div className="grid gap-6 md:grid-cols-3">
172-
{[
173-
{ icon: Cpu, title: 'Run anything locally', body: 'Download the latest open models — text, vision, image, voice, speech — and run them through one local gateway. No API keys.' },
174-
{ icon: LockKey, title: 'Truly private', body: 'Nothing leaves your device. No cloud, no telemetry, no account. Your conversations and files stay on your machine.' },
175-
{ icon: Sparkle, title: 'Never forgets', body: 'Pro lands July 2026: always on, it remembers everything you see and do, makes it instantly findable with unified search, and a proactive secretary surfaces what matters and acts for you. Join early access — or pay now for lifetime free + first access.', pro: true },
176-
].map(({ icon: Icon, title, body, pro }, i) => (
192+
<div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-4">
193+
{PRO_GRID.map(({ icon: Icon, label, line }, i) => (
177194
<motion.div
178-
key={title}
179-
initial={{ opacity: 0, y: 30 }}
195+
key={label}
196+
initial={{ opacity: 0, y: 20 }}
180197
animate={{ opacity: 1, y: 0 }}
181-
transition={{ delay: 0.3 + i * 0.2, duration: 0.5 }}
182-
className="group relative overflow-hidden rounded-2xl border border-neutral-800 bg-neutral-900/50 p-8"
198+
transition={{ delay: 0.3 + i * 0.06, duration: 0.4 }}
199+
className="group relative overflow-hidden rounded-xl border border-neutral-800 bg-neutral-900/50 p-4 transition-colors hover:border-green-500/30"
183200
>
184-
<BorderBeam duration={8} size={80} className={cn('opacity-0 transition-opacity group-hover:opacity-40', pro ? 'from-transparent via-green-500 to-transparent' : 'from-transparent via-neutral-700 to-transparent')} />
185-
{pro && <span className="absolute right-4 top-4 rounded-full border border-green-500/30 bg-green-500/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-green-400">Pro</span>}
186-
<div className={cn('mb-6 flex h-12 w-12 items-center justify-center rounded-xl border', pro ? 'border-green-500/30 bg-green-500/10' : 'border-neutral-700 bg-neutral-800')}>
187-
<Icon className={cn('h-5 w-5', pro ? 'text-green-400' : 'text-neutral-400')} weight="regular" />
201+
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg border border-neutral-700 bg-neutral-800 transition-colors group-hover:border-green-500/30 group-hover:bg-green-500/10">
202+
<Icon className="h-4 w-4 text-neutral-400 transition-colors group-hover:text-green-400" weight="regular" />
188203
</div>
189-
<h3 className="mb-3 text-lg font-medium text-white">{title}</h3>
190-
<p className="text-sm leading-relaxed text-neutral-500">{body}</p>
204+
<h3 className="mb-1 text-[13px] font-medium uppercase tracking-wide text-white">{label}</h3>
205+
<p className="text-xs leading-relaxed text-neutral-500">{line}</p>
191206
</motion.div>
192207
))}
193208
</div>
194209

210+
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 1 }} className="mt-8 text-center text-xs text-neutral-600">
211+
Pro is live now. $49/year or $69 once - one license across up to 5 devices.
212+
</motion.p>
213+
</div>
214+
</motion.div>
215+
)}
216+
217+
{/* Step 3 — Private close */}
218+
{currentStep === 3 && (
219+
<motion.div key="step-3" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="relative flex h-full w-full flex-col items-center justify-center bg-neutral-950 px-6">
220+
<GridBackdrop className="opacity-70" />
221+
<div className="relative z-10 mx-auto max-w-2xl text-center">
222+
<TextGenerate words="It all runs in your Mac's RAM." className="text-3xl font-semibold tracking-tight text-white md:text-5xl" delay={0} />
223+
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.6 }} className="mx-auto mt-5 max-w-xl text-neutral-400">
224+
No account, no API key, no telemetry. Inference happens on your CPU and GPU. Turn off wifi and it keeps working. You can verify it yourself.
225+
</motion.p>
226+
195227
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 1, duration: 0.5 }} className="mt-12 flex items-center justify-center gap-12">
196228
<div className="text-center">
197229
<div className="text-3xl font-light text-green-400"><AnimatedNumber value={100} delay={1.2} />%</div>

src/renderer/src/components/Settings.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ function SettingsCard({
5050
}
5151

5252
// A Pro section shown (disabled) in the free build: title + description + a
53-
// "Pro · July 2026" badge, dimmed and non-interactive.
53+
// "Pro" badge, dimmed and non-interactive.
5454
function ProPlaceholder({ title, description, delay = 0.18 }: { title: string; description: string; delay?: number }): React.ReactElement {
5555
return (
5656
<motion.div
@@ -60,7 +60,7 @@ function ProPlaceholder({ title, description, delay = 0.18 }: { title: string; d
6060
transition={{ duration: 0.6, delay }}
6161
>
6262
<span className="absolute right-4 top-4 inline-flex items-center gap-1 rounded-full border border-green-500/30 bg-green-500/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-green-400">
63-
<LockKey weight="bold" className="h-3 w-3" /> Pro · July 2026
63+
<LockKey weight="bold" className="h-3 w-3" /> Pro
6464
</span>
6565
<h3 className="mb-1 pr-28 text-base font-medium text-neutral-300">{title}</h3>
6666
<p className="text-sm text-neutral-600">{description}</p>

src/renderer/src/components/pro/UpgradeScreen.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState } from 'react';
2-
import { ArrowSquareOut, Check, Sparkle, Key, CircleNotch } from '@phosphor-icons/react';
2+
import { ArrowSquareOut, Check, Sparkle, Key, CircleNotch, DeviceMobile } from '@phosphor-icons/react';
33
import { PRO_PAY_URL, PRO_FEATURES, type ProFeature } from './proCatalog';
4+
import { OFF_GRID_MOBILE_URL, openExternal } from '../../constants/links';
45

56
// License-key activation. Only meaningful in a pro-capable build (__OFFGRID_PRO__);
67
// a core build has no pro code bundled, so entering a key would unlock nothing.
@@ -161,6 +162,23 @@ export function UpgradeScreen({ feature }: { feature?: ProFeature }): React.Reac
161162
<LicenseActivation />
162163
</>
163164
) : null}
165+
166+
{/* Cross-sell: your Pro license spans both products. Mirrors mobile's
167+
"Get Off Grid AI Desktop" row on its Pro tab. */}
168+
<div className="border-t border-neutral-800" />
169+
<button
170+
onClick={() => openExternal(OFF_GRID_MOBILE_URL)}
171+
className="group flex items-center gap-3 rounded-lg border border-neutral-800 bg-neutral-900/40 px-3 py-2.5 text-left transition-colors hover:border-green-500/30"
172+
>
173+
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-neutral-700 bg-neutral-800 transition-colors group-hover:border-green-500/30">
174+
<DeviceMobile weight="regular" className="h-4 w-4 text-neutral-300 transition-colors group-hover:text-green-400" />
175+
</span>
176+
<span className="min-w-0 flex-1">
177+
<span className="block text-xs font-medium text-neutral-200">Get Off Grid AI Mobile</span>
178+
<span className="mt-0.5 block text-[11px] leading-tight text-neutral-500">Your license covers your phone too - up to 5 devices, synced over your own network.</span>
179+
</span>
180+
<ArrowSquareOut weight="bold" className="h-4 w-4 shrink-0 text-neutral-500" />
181+
</button>
164182
</aside>
165183
</div>
166184
</div>

src/renderer/src/components/pro/proCatalog.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,7 @@ import type { ComponentType } from 'react';
1919
// pro/ submodule is present and activated, the real screens (registered via
2020
// screenRegistry/navRegistry) take over these same routes.
2121

22-
/** When Pro launches. */
23-
export const PRO_LAUNCH = 'July 2026';
24-
/** Join the Pro early-access waitlist (free to register). */
25-
export const PRO_EARLY_ACCESS_URL = 'https://getoffgridai.co/early-access/';
26-
/** Pay now → lifetime free + first access when Pro ships. */
22+
/** Buy Pro — live now, $49/year or $69 once, one license across up to 5 devices. */
2723
export const PRO_PAY_URL = 'https://getoffgridai.co/pay';
2824

2925
export interface ProFeature {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// External links out of the app. Kept in one place so a URL change is a single
2+
// edit and cross-sell surfaces stay consistent. These open in the user's browser
3+
// via window.api.openExternal (never in-app).
4+
5+
/** The main marketing site. */
6+
export const OFF_GRID_WEBSITE_URL = 'https://getoffgridai.co';
7+
8+
/** Off Grid AI Mobile landing page — carries both App Store and Google Play links.
9+
* Mirrors mobile's link to getoffgridai.co/desktop. */
10+
export const OFF_GRID_MOBILE_URL = 'https://getoffgridai.co/mobile';
11+
12+
/** Open a URL in the user's default browser (falls back to window.open). */
13+
export function openExternal(url: string): void {
14+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
15+
const api = (window as any).api;
16+
if (api?.openExternal) api.openExternal(url);
17+
else window.open(url, '_blank');
18+
}

0 commit comments

Comments
 (0)