Your lock screen

Tap to go deeper ›

Saves to Photos at full iPhone resolution. Set it as your Lock Screen — your Home Screen stays as it is.

Where verses open

Tap a verse reference and it opens in whichever app you already use. Your choice, not ours.

Share it

Share the gift of being on purpose with a friend.

Put it on your phone

1 · Auto-set the lock screen every morning+
  1. Open ShortcutsAutomation+Time of Day. Pick your wake time, set Run Immediately and turn off Notify When Run.
  2. Add action Get Contents of URL. For scripture use /w/verses/ plus today's date, or let the shortcut build it: set the URL to https://YOUR-DOMAIN/w/verses/ and append a Format Date action using the format MM-dd followed by .jpg.
  3. Add action Set Wallpaper Photo. Open its options and turn Home Screen off, Lock Screen on.
  4. Done. Every morning the verse is already there before you unlock.
2 · Add the tappable "go deeper" widget+
  1. Install Scriptable from the App Store (free).
  2. Create a new script, paste in lockscreen-widget.js, name it Daily Begin.
  3. Long-press your Lock Screen → Customise → tap the widget row under the clock → add Scriptable → choose the Daily Begin script.
  4. Now tapping that tile asks for Face ID, then opens straight to today's devotional. Ignore it and your phone unlocks to your icons as normal.
3 · Keep the app one tap away+
  • In Safari tap ShareAdd to Home Screen.
  • It runs full-screen and works offline once loaded.
Face ID
+ (c / 100).toFixed(2).replace(/\.00$/, '') function renderCompanions() { $('#companions').innerHTML = Object.entries(COMPANIONS).map(([id, c]) => ``).join('') for (const btn of $('#companions').querySelectorAll('.comp')) btn.addEventListener('click', () => { state.companion = btn.dataset.companion store.set('companion', state.companion) renderCompanions() render() }) } async function renderPlans() { if (!FREE_MODE_OFF) return // free for now: no pricing, no checkout const a = account.get() const data = await (await fetch(`/api/pricing${a ? `?accountId=${a.id}` : ''}`)).json() $('#plans').innerHTML = Object.entries(data.skus).map(([sku, q]) => { const discounted = q.discount_cents > 0 || q.credit_cents > 0 const lines = [] // One track means choosing one — ask before they pay, not after. const picker = sku === 'single' ? `
` : '' return `
${sku === 'both' ? 'Both tracks' : 'One track'}
${money(q.total_cents)}${discounted ? `${money(q.list_cents)}` : ''}
${picker}
${lines.join('') || (sku === 'both' ? '
Faith + Leadership
All 366 days
Opens in your app of choice
' : '
All 366 days
Opens in your app of choice
Switch tracks any time
')}
` }).join('') $('#pay-note').textContent = !a ? 'Add your email below first.' : data.mode === 'simulation' ? 'Payments are in SIMULATION — no Stripe key is configured, so no money moves.' : data.mode === 'test' ? 'Stripe TEST mode. Use card 4242 4242 4242 4242.' : 'Secure payment via Stripe.' for (const b of $('#plans').querySelectorAll('.pick')) b.addEventListener('click', () => { state.buyTrack = b.dataset.pick store.set('buyTrack', state.buyTrack) renderPlans() }) for (const btn of $('#plans').querySelectorAll('button[data-sku]')) btn.addEventListener('click', () => checkout(btn.dataset.sku, btn)) } // Stripe.js, loaded only when someone actually goes to pay. let stripeLib = null async function loadStripe() { if (stripeLib) return stripeLib const { publishableKey } = await (await fetch('/api/publishable')).json() if (!publishableKey) return null await new Promise((resolve, reject) => { const s = document.createElement('script') s.src = 'https://js.stripe.com/v3/' s.onload = resolve s.onerror = reject document.head.appendChild(s) }) stripeLib = window.Stripe(publishableKey) return stripeLib } let mounted = null async function openPayment(sku, q) { const a = account.get() const res = await (await fetch('/api/checkout/embedded', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountId: a.id, sku, track: sku === 'single' ? state.buyTrack : null }), })).json() if (res.error) throw new Error(res.error) if (res.simulated || res.free) return res const stripe = await loadStripe() if (!stripe) throw new Error('payments unavailable') $('#pay-plan').textContent = q.label $('#pay-total').textContent = money(q.total_cents) $('#pay-panel').hidden = false $('#pay-panel').scrollIntoView({ behavior: 'smooth', block: 'center' }) if (mounted) { mounted.destroy(); mounted = null } mounted = await stripe.initEmbeddedCheckout({ clientSecret: res.clientSecret }) mounted.mount('#stripe-mount') return null } function closePayment() { if (mounted) { mounted.destroy(); mounted = null } $('#pay-panel').hidden = true } async function checkout(sku, btn) { const a = account.get() if (!a) { $('#signup')?.scrollIntoView({ behavior: 'smooth' }); return } btn.disabled = true btn.textContent = 'Working…' try { const pricing = await (await fetch(`/api/pricing?accountId=${a.id}`)).json() const r = await openPayment(sku, pricing.skus[sku]) if (!r) { btn.disabled = false; btn.textContent = 'Continue'; return } // form is open $('#pay-note').textContent = r.simulated ? `Simulated purchase recorded (${money(r.purchase.paid_cents)}). No money moved.` : `Purchase complete.` await refreshAccount() renderPlans() renderAccount() } catch (e) { $('#pay-note').textContent = e.message btn.disabled = false btn.textContent = 'Continue' } } async function refreshAccount() { const a = account.get() if (!a) return const fresh = await (await fetch('/api/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: a.email }), })).json() if (!fresh.error) account.set(fresh) } // Coming back from Stripe: confirm server-side, never trust the redirect alone. async function handleReturn() { const p = new URLSearchParams(location.search) if (p.get('checkout') !== 'success' || !p.get('session_id')) return const r = await (await fetch('/api/checkout/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: p.get('session_id') }), })).json() $('#pay-note').textContent = r.error ? `Payment not confirmed: ${r.error}` : 'Payment confirmed — thank you.' await refreshAccount() } // The device already knows where it is, so we never ask. Re-send it on every open: // people travel, and a stale timezone means the wallpaper lands at the wrong hour. async function syncTimezone() { const a = account.get() if (!a) return const tz = Intl.DateTimeFormat().resolvedOptions().timeZone if (!tz || tz === store.get('tz_sent')) return try { await fetch('/api/wake', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountId: a.id, timezone: tz }), }) store.set('tz_sent', tz) } catch {} } async function logRead(surface) { const a = account.get() try { await fetch('/api/reads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountId: a?.id || null, dayKey: key(), track: state.track, surface }), }) } catch {} } let VALID_DAYS = new Set() async function render() { window.__stage = 'render-enter' const track = state.track window.__stage = 'loading-data' await load(track) window.__stage = 'data-loaded' if (!VALID_DAYS.size) VALID_DAYS = new Set(Object.keys(state.data[track])) const t = TRACKS[track] const k = key() const acct = account.get() const isBirthday = Boolean(acct?.birthday) && acct.birthday === k const bday = isBirthday ? await loadBirthday() : null const entry = bday ? bday[track] : state.data[track][k] const devo = bday ? bday[track] : state.devo[track][k] document.body.classList.toggle('is-birthday', isBirthday) document.body.dataset.track = track $('#date').textContent = longDate() $('#kicker').textContent = isBirthday ? 'HAPPY BIRTHDAY' : t.kicker $('#verse').textContent = `“${entry.text}”` $('#ref').textContent = entry.ref for (const btn of document.querySelectorAll('.track-btn, .ls-track')) btn.setAttribute('aria-pressed', String(btn.dataset.track === track)) const d = $('#devotional') if (devo) { d.innerHTML = `

${devo.title}

${devo.body.map((p) => `

${p}

`).join('')}
Reflect

${devo.reflect}

Today, do this

${devo.act}

` d.hidden = false $('#no-devo').hidden = true } else { d.hidden = true $('#no-devo').hidden = false } // Lock-screen chrome, so the preview matches what appears on the phone. const now = new Date() $('#lock-date').textContent = now.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' }) $('#lock-time').textContent = now.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }).replace(/\s?[ap]m/i, '') $('#lw-kicker').textContent = track === 'verses' ? 'FAITH' : 'LEADERSHIP' $('#lw-ref').textContent = entry.ref // Faith days hand off to whichever Bible or devotional app they chose. const bible = $('#bible-link') const link = track === 'verses' ? companionLink(entry.ref, state.companion) : null bible.hidden = !link if (link) { bible.href = link.url; bible.textContent = link.label } $('#ls-kicker').textContent = isBirthday ? 'HAPPY BIRTHDAY' : (track === 'verses' ? 'FAITH' : 'LEADERSHIP') $('#ls-ref').textContent = entry.ref // Same wallpaper, drawn twice: full-bleed behind the lock screen, and small in the studio. window.__stage = 'drawing' renderWallpaper($('#ls-canvas'), entry, track, state.photo) window.__stage = 'drawn' renderWallpaper($('#wallpaper'), entry, track, state.photo) } function downloadWallpaper() { // Full resolution, off-screen, so the phone never holds two big canvases at once. const full = document.createElement('canvas') renderWallpaper(full, state.data[state.track][key()], state.track, state.photo, true) full.toBlob((blob) => { const a = document.createElement('a') a.href = URL.createObjectURL(blob) a.download = `${APP_NAME.toLowerCase()}-${state.track}-${key()}.jpg` a.click() setTimeout(() => URL.revokeObjectURL(a.href), 1000) }, 'image/jpeg', 0.92) } function initEvents() { for (const btn of document.querySelectorAll('.track-btn')) btn.addEventListener('click', () => { state.track = btn.dataset.track store.set('track', state.track) render() }) $('#save').addEventListener('click', downloadWallpaper) // Apple exports HEIC files that are frequently *named* .jpg, so the extension and the // MIME type both lie. Only the first bytes tell the truth. const realFormat = async (file) => { const b = new Uint8Array(await file.slice(0, 12).arrayBuffer()) if (String.fromCharCode(...b.slice(4, 8)) === 'ftyp') { const brand = String.fromCharCode(...b.slice(8, 12)) return /heic|heix|hevc|mif1|msf1/i.test(brand) ? 'heic' : 'video' } if (b[0] === 0xff && b[1] === 0xd8) return 'jpeg' if (b[0] === 0x89 && b[1] === 0x50) return 'png' if (String.fromCharCode(...b.slice(0, 4)) === 'RIFF') return 'webp' if (String.fromCharCode(...b.slice(0, 4)) === 'GIF8') return 'gif' return 'unknown' } $('#photo').addEventListener('change', async (e) => { const file = e.target.files[0] if (!file) return const note = $('#photo-note') note.textContent = 'Loading photo…' note.hidden = false const fmt = await realFormat(file) if (fmt === 'heic') { note.innerHTML = `${file.name} is a HEIC photo${/\.jpe?g$/i.test(file.name) ? ' despite its .jpg name' : ''} — no browser can read Apple’s format. Open it in Preview › File › Export › JPEG, or on iPhone set Settings › Camera › Formats › Most Compatible.` return } const img = new Image() const url = URL.createObjectURL(file) img.onload = () => { URL.revokeObjectURL(url) state.photo = img note.hidden = true render() } img.onerror = () => { URL.revokeObjectURL(url) note.textContent = `That file could not be read (detected: ${fmt}). Try a JPG or PNG.` } img.src = url }) $('#clear-photo').addEventListener('click', () => { state.photo = null $('#photo').value = '' render() }) for (const h of document.querySelectorAll('.step-head')) h.addEventListener('click', () => h.parentElement.toggleAttribute('open')) $('#signup').addEventListener('submit', async (e) => { e.preventDefault() const f = Object.fromEntries(new FormData(e.target)) const btn = e.target.querySelector('button') btn.disabled = true try { await signUp(f.email, f.name) renderAccount() } catch (err) { alert(err.message) } finally { btn.disabled = false } }) $('#share').addEventListener('click', async () => { const a = account.get() const url = shareUrl(a.referral_code) const text = `Share the gift of being on purpose. This is what I start my day with.` if (navigator.share) { try { await navigator.share({ title: APP_NAME, text, url }) } catch {} } else { await navigator.clipboard.writeText(url) $('#share').textContent = 'Link copied' setTimeout(() => ($('#share').textContent = 'Share your link'), 1800) } }) $('#copy-link').addEventListener('click', async () => { await navigator.clipboard.writeText($('#my-link').value) $('#copy-link').textContent = 'Copied' setTimeout(() => ($('#copy-link').textContent = 'Copy'), 1500) }) $('#sign-out').addEventListener('click', () => { account.clear(); renderAccount() }) $('#pay-close').addEventListener('click', closePayment) $('#wake-form').addEventListener('submit', async (e) => { e.preventDefault() const a = account.get() if (!a) return const wakeTime = $('#wake-time').value await fetch('/api/wake', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountId: a.id, wakeTime, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }), }) const [h, m] = wakeTime.split(':').map(Number) const ready = new Date(); ready.setHours(h, m - 30, 0, 0) const note = $('#wake-note') note.textContent = `Set. Tomorrow's word is rendered and waiting by ${ready.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })} — thirty minutes before you're up.` note.hidden = false }) $('#bday-form').addEventListener('submit', async (e) => { e.preventDefault() const a = account.get() if (!a) return const val = $('#bday-input').value.trim() const note = $('#bday-note') if (!/^\d{2}-\d{2}$/.test(val) || !VALID_DAYS.has(val)) { note.textContent = 'That is not a real date. Use MM-DD, like 03-14.' note.hidden = false return } await fetch('/api/wake', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountId: a.id, birthday: val }), }) const [m, d] = val.split('-').map(Number) const when = new Date(2026, m - 1, d).toLocaleDateString(undefined, { month: 'long', day: 'numeric' }) note.textContent = `Saved. On ${when} the word waiting for you will be chosen for the day.` note.hidden = false }) $('#devo-back').addEventListener('click', relock) $('#start-day').addEventListener('click', startDay) $('#devo-more').addEventListener('click', () => show('app')) $('#back-to-lock').addEventListener('click', relock) } // "Start my day" closes the app out. A web app cannot force-quit itself or jump to the // iOS home screen, so it returns to the resting state; the native build can truly exit. function startDay() { const s = $('#devo-screen') s.style.transition = 'opacity .45s ease, transform .45s ease' s.style.opacity = '0' s.style.transform = 'scale(.96)' setTimeout(() => { s.style.cssText = '' relock() }, 450) } // ---------- the lock screen ---------- // Three screens: lock -> devotional -> the rest. The widget goes to the devotional; // swiping up goes past it, exactly like a phone. function show(screen) { $('#lockscreen').classList.toggle('gone', screen !== 'lock') $('#devo-screen').hidden = screen !== 'devotional' $('#app').hidden = screen !== 'app' if (screen !== 'lock') window.scrollTo(0, 0) } let unlocked = false function tickClock() { const now = new Date() $('#ls-date').textContent = now.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' }) $('#ls-time').textContent = now .toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) .replace(/\s?[ap]\.?m\.?/i, '') } function unlock(toDevotional = false) { if (unlocked) return unlocked = true show(toDevotional ? 'devotional' : 'app') logRead(toDevotional ? 'lockscreen' : 'app') } function relock() { unlocked = false show('lock') } // Tapping the widget authenticates first, exactly like the real thing. function authenticateThenOpen() { const auth = $('#auth') auth.classList.add('on') if (navigator.vibrate) navigator.vibrate(12) setTimeout(() => { auth.classList.remove('on') unlock(true) }, 900) } function initLockScreen() { tickClock() setInterval(tickClock, 10000) $('#ls-widget').addEventListener('click', (e) => { e.stopPropagation(); authenticateThenOpen() }) $('#ls-unlock').addEventListener('click', (e) => { e.stopPropagation(); unlock(false) }) // Switch tracks without leaving the lock screen. for (const btn of document.querySelectorAll('.ls-track')) btn.addEventListener('click', (e) => { e.stopPropagation() state.track = btn.dataset.track store.set('track', state.track) if (navigator.vibrate) navigator.vibrate(8) render() }) // Real swipe-up: follows the finger, and only unlocks past a threshold. const ls = $('#lockscreen') let startY = null const down = (y) => { startY = y; ls.classList.add('dragging') } const move = (y) => { if (startY === null) return const dy = Math.min(0, y - startY) ls.style.transform = `translateY(${dy}px)` ls.style.opacity = String(Math.max(0, 1 + dy / (window.innerHeight * 0.55))) } const up = (y) => { if (startY === null) return const dy = y - startY ls.classList.remove('dragging') ls.style.transform = '' ls.style.opacity = '' startY = null if (dy < -70) unlock(false) } ls.addEventListener('touchstart', (e) => down(e.touches[0].clientY), { passive: true }) ls.addEventListener('touchmove', (e) => move(e.touches[0].clientY), { passive: true }) ls.addEventListener('touchend', (e) => up(e.changedTouches[0].clientY)) ls.addEventListener('mousedown', (e) => down(e.clientY)) window.addEventListener('mousemove', (e) => move(e.clientY)) window.addEventListener('mouseup', (e) => up(e.clientY)) // Anywhere else on the lock screen, or any key, behaves like a normal unlock. ls.addEventListener('click', () => unlock(false)) window.addEventListener('keydown', () => unlock(false), { once: true }) } // Deep link: the Lock Screen widget opens ?view=devotional so the tap lands on the depth, // not the top of the page. function applyDeepLink() { const p = new URLSearchParams(location.search) if (p.get('track') && TRACKS[p.get('track')]) { state.track = p.get('track') store.set('track', state.track) } // Arriving from the real iOS widget: the phone already authenticated, so skip // straight past the simulated lock screen. if (p.get('view') === 'devotional') requestAnimationFrame(() => unlock(true)) } initLockScreen() initEvents() renderAccount() syncTimezone() renderCompanions() renderPlans() handleReturn() render().then(() => { applyDeepLink(); diag() }) // ?debug=1 prints what actually happened on THIS device. function diag() { if (!new URLSearchParams(location.search).has('debug')) return const c = $('#ls-canvas') let painted = '?' try { const d = c.getContext('2d').getImageData(Math.round(c.width / 2), Math.round(c.height * 0.6), 1, 1).data painted = (d[0] + d[1] + d[2] > 12) ? 'yes' : 'NO (blank)' } catch (e) { painted = 'ERROR ' + e.message } const el = $('#diag') el.hidden = false el.textContent = [ 'BUILD ' + BUILD, 'ua ' + navigator.userAgent.slice(0, 90), 'viewport ' + innerWidth + 'x' + innerHeight + ' dpr=' + (devicePixelRatio || 1), 'canvas ' + c.width + 'x' + c.height + ' painted=' + painted, 'clock "' + $('#ls-time').textContent + '"', 'ref "' + $('#ls-ref').textContent + '"', 'data days ' + Object.keys(state.data[state.track] || {}).length, 'devotions ' + Object.keys(state.devo[state.track] || {}).length, 'sw ' + (navigator.serviceWorker && navigator.serviceWorker.controller ? 'controlling' : 'none'), 'cq units ' + (window.CSS && CSS.supports('width: 1cqw')), 'storage ' + (store.available ? 'localStorage' : 'BLOCKED — using memory'), ].join('\n') } $('#build').textContent = BUILD document.body.dataset.booted = '1' // boot guard: proves app.js executed