diff --git a/index.html b/index.html
index d5d86d8..4817c9d 100644
--- a/index.html
+++ b/index.html
@@ -2,7 +2,13 @@
-
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 0000000..e8b6b66
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Grateful Journal",
+ "short_name": "Grateful",
+ "description": "Your private, encrypted gratitude journal",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#f0fdf4",
+ "theme_color": "#16a34a",
+ "orientation": "portrait",
+ "icons": [
+ {
+ "src": "/icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ },
+ {
+ "src": "/icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/src/App.css b/src/App.css
index a2cbf43..f8c5d99 100644
--- a/src/App.css
+++ b/src/App.css
@@ -1443,6 +1443,10 @@
background: rgba(59, 130, 246, 0.12);
color: #3b82f6;
}
+.settings-item-icon-purple {
+ background: rgba(139, 92, 246, 0.12);
+ color: #7c3aed;
+}
.settings-item-content {
flex: 1;
@@ -2035,6 +2039,59 @@
cursor: not-allowed;
}
+/* iOS Install Modal */
+.ios-install-modal {
+ text-align: left;
+}
+
+.ios-install-icon {
+ font-size: 2.5rem;
+ text-align: center;
+ margin-bottom: 0.5rem;
+}
+
+.ios-install-modal .confirm-modal-title {
+ color: #1a1a1a;
+ text-align: center;
+}
+
+.ios-install-subtitle {
+ font-size: 0.85rem;
+ color: #6b7280;
+ margin: 0 0 1rem;
+ text-align: center;
+}
+
+.ios-install-steps {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.ios-install-steps li {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ font-size: 0.875rem;
+ color: #374151;
+ line-height: 1.4;
+}
+
+.ios-install-step-icon {
+ width: 34px;
+ height: 34px;
+ flex-shrink: 0;
+ background: rgba(139, 92, 246, 0.1);
+ color: #7c3aed;
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
/* ============================
RESPONSIVE – DESKTOP (≥ 860px)
Sidebar navigation + single-column content
@@ -2407,6 +2464,24 @@
color: #60a5fa;
}
+[data-theme="dark"] .settings-item-icon-purple {
+ background: rgba(167, 139, 250, 0.12);
+ color: #a78bfa;
+}
+
+[data-theme="dark"] .ios-install-modal .confirm-modal-title {
+ color: #e8f5e8;
+}
+
+[data-theme="dark"] .ios-install-steps li {
+ color: #d1d5db;
+}
+
+[data-theme="dark"] .ios-install-step-icon {
+ background: rgba(167, 139, 250, 0.12);
+ color: #a78bfa;
+}
+
/* -- Settings buttons -- */
[data-theme="dark"] .settings-tutorial-btn {
background: #1a1a1a;
diff --git a/src/hooks/usePWAInstall.ts b/src/hooks/usePWAInstall.ts
new file mode 100644
index 0000000..9a5f117
--- /dev/null
+++ b/src/hooks/usePWAInstall.ts
@@ -0,0 +1,61 @@
+import { useState, useEffect } from 'react'
+
+interface BeforeInstallPromptEvent extends Event {
+ prompt: () => Promise
+ userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
+}
+
+interface PWAInstall {
+ canInstall: boolean // Android/Chrome: native prompt available
+ isIOS: boolean // iOS Safari: must show manual instructions
+ isInstalled: boolean // Already running as installed PWA
+ triggerInstall: () => Promise
+}
+
+export function usePWAInstall(): PWAInstall {
+ const [deferredPrompt, setDeferredPrompt] = useState(null)
+ const [isInstalled, setIsInstalled] = useState(false)
+
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as unknown as { MSStream?: unknown }).MSStream
+
+ useEffect(() => {
+ // Detect if already installed (standalone mode)
+ const mq = window.matchMedia('(display-mode: standalone)')
+ const iosStandalone = (navigator as unknown as { standalone?: boolean }).standalone === true
+ if (mq.matches || iosStandalone) {
+ setIsInstalled(true)
+ return
+ }
+
+ const handler = (e: Event) => {
+ e.preventDefault()
+ setDeferredPrompt(e as BeforeInstallPromptEvent)
+ }
+
+ window.addEventListener('beforeinstallprompt', handler)
+
+ window.addEventListener('appinstalled', () => {
+ setIsInstalled(true)
+ setDeferredPrompt(null)
+ })
+
+ return () => window.removeEventListener('beforeinstallprompt', handler)
+ }, [])
+
+ const triggerInstall = async () => {
+ if (!deferredPrompt) return
+ await deferredPrompt.prompt()
+ const { outcome } = await deferredPrompt.userChoice
+ if (outcome === 'accepted') {
+ setIsInstalled(true)
+ setDeferredPrompt(null)
+ }
+ }
+
+ return {
+ canInstall: !!deferredPrompt,
+ isIOS,
+ isInstalled,
+ triggerInstall,
+ }
+}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index d698ce4..e07093a 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom'
import BottomNav from '../components/BottomNav'
import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour'
import { PageLoader } from '../components/PageLoader'
+import { usePWAInstall } from '../hooks/usePWAInstall'
const MAX_PHOTO_SIZE = 200 // px — resize to 200x200
@@ -51,6 +52,8 @@ export default function SettingsPage() {
const { continueTourOnSettings } = useOnboardingTour()
const navigate = useNavigate()
const fileInputRef = useRef(null)
+ const { canInstall, isIOS, isInstalled, triggerInstall } = usePWAInstall()
+ const [showIOSModal, setShowIOSModal] = useState(false)
// Edit profile modal state
const [showEditModal, setShowEditModal] = useState(false)
@@ -282,6 +285,34 @@ export default function SettingsPage() {
+ {/* App */}
+ {!isInstalled && (isIOS || canInstall) && (
+
+ APP
+
+
+
+
+ )}
+
{/* Data & Look */}
DATA & LOOK
@@ -495,6 +526,55 @@ export default function SettingsPage() {
)}
+ {/* iOS "Add to Home Screen" instructions modal */}
+ {showIOSModal && (
+ setShowIOSModal(false)}>
+
e.stopPropagation()}>
+
🌱
+
Add to Home Screen
+
Follow these steps in Safari:
+
+ -
+
+
+
+ Tap the Share button at the bottom of Safari
+
+ -
+
+
+
+ Scroll down and tap Add to Home Screen
+
+ -
+
+
+
+ Tap Add to confirm
+
+
+
+
+
+ )}
+
)