Bold Little Oracle — App Enhancement Spec (v1.2+)
Prioritized implementation spec for the living-content strategy. Ordered by impact-per-effort. Codebase citations verified against /Users/joshua/Projects/Unicorn.Land/bold-little-oracle/src on 2026-07-16 (Expo SDK 54, RN 0.81, expo-router 6, expo-iap 4.5, AsyncStorage-only, zero network calls today).
Ground rules that bind every item below: no analytics SDKs, no accounts, no push tokens, no auto-renewable SKUs, no generated card text ever. Bundled deck remains the permanent offline floor. Everything a lifetime unlocker has today keeps working forever.
v1.2 — one binary, one review cycle
1. Remote Content Module (lib/remoteContent.ts — new file)
Effort: M (~2 dev days) · Unlocks: the entire living-content strategy — Week’s Card, Missing Cards pack, Second Printing, kill switch. Nothing else on this page ships without it.
What. A manifest-first content fetcher against Cloudflare Pages: content/weekly/current.json, content/packs/<slug>/manifest.json, content/deck/base.json. ETag-cached in AsyncStorage, bundled deck as fallback, schemaVersion/minAppVersion guards, same-day kill switch via git push. Fetch fires only on foreground app open — never background refresh, never notification prefetch. This is load-bearing: the weekly-open gate counts these GETs as engagement, so an OS-scheduled fetch would corrupt the only metric that gates all paid work.
Why (strategy). Today new cards require an App Store build: lib/draw.ts:4 does import deckData from '../content/base-deck.json' and everything downstream is static. The strategy’s headline — “a free new hand-written card every week” — is impossible without this module. It also carries the two-frame provenance schema (SHADE R2) and the kill switch (weekly card meets a live tragedy → pull/replace same day).
UX sketch. Invisible when it works. On app open (foreground), a fire-and-forget fetch updates the cache; UI reads cache-first, so there is never a spinner blocking the draw. Airplane mode: app behaves exactly as v1.1 — bundled deck, all features. If a remote deck fails validation or minAppVersion exceeds the running app, the module silently serves the last good cache or the bundled floor. No error states surface to the user, ever; the worst case is “the card is last week’s.”
Technical approach.
- New lib/remoteContent.ts. Public API: getWeeklyCard(): Promise<WeeklyCard | null>, getActiveDeck(): Promise<DeckCard[]> (remote base deck if cached+valid, else bundled), getPackManifest(slug), refreshContent() (called from the root layout on AppState → active).
- Wire the refresh in app/_layout.tsx with an AppState.addEventListener('change', ...) listener — this is the only trigger. Do not add expo-background-fetch or BGAppRefreshTask; the strategy explicitly forbids it.
- Storage: extend the existing blo: key convention in lib/storage.ts (which currently defines blo:tone, blo:history, blo:streak, blo:purchase_status, etc.) with blo:content_cache:<path> + blo:content_etag:<path>. Send If-None-Match; a 304 costs nothing and still counts on the Worker.
- Validation: generalize validateDeck() in lib/draw.ts:11-72. It currently hardcodes deck.length !== 72 (line 12) and card.id < 1 || card.id > 72 (line 40). Refactor to validateDeck(deck, expectedSize) and validate the tone keys / theme enum exactly as it does now (lines 49–65) — the remote schema reuses the existing DeckCard shape from lib/types.ts (front/back each with pg/sassy).
- Also fix while in there: draw() at lib/draw.ts:77-78 re-validates all 72 cards on every draw. Validate once at load/cache-write time, not per-draw.
- Weekly card schema carries provenance: "fresh" | "bench". The rendering frame is part of the schema contract: fresh → “written this week, by hand”; bench → “a card for any week like this one”. The UI must render whichever frame the JSON declares — no client-side freshness claims.
- Kill switch = the manifest itself: publishing a replacement current.json (or a tombstone {"pulled": true}) via git push to Pages replaces the card on next foreground open.
- Companion (not app code, but a v1.2 blocker): the count-only Worker on the three content routes writing (path, status-class, ISO-week) to Workers Analytics Engine — no IPs, no UAs, no cookies. Verify the Worker + Analytics Engine binding works on the current Cloudflare plan before v1.2 submission; if it fails, v1.2 does not ship (the weekly-open gate would have no sensor).
2. Deterministic Daily Card + Anti-Repeat (lib/draw.ts)
Effort: S (~1 day) · Unlocks: “today’s card” framing everywhere — notification deep link, share card, future widget; fixes a real product flaw.
What. The free daily draw becomes date-seeded (same card all day, stable across app restarts) and excludes the trailing ~14 drawn card ids. Premium unlimited draws stay uniform-random but also get the anti-repeat window.
Why (strategy). Rung 0 of the offer ladder is “1 deterministic daily card.” Today draw() at lib/draw.ts:83 is Math.floor(Math.random() * deckData.length) — redraw after force-quit or reinstall gives a different card, repeats are common in a 72-card deck, and there is no stable “today’s card” object for the notification or share loop to point at.
UX sketch. Free user opens the app: the same card they saw at breakfast is still “today’s card” at dinner. Tomorrow at local midnight it rolls (the reset boundary already exists — isSameDay in lib/drawLimits.ts:83-89 and getNextMidnight at :91-96). No visible mechanic changes; the shuffle animation in app/index.tsx still plays, it just lands on the deterministic card for free-tier daily draws.
Technical approach.
- Add drawDaily(tone: Tone, dateKey: string): DrawResult alongside draw(). Seed = FNV-1a or mulberry32 over installSalt + YYYY-MM-DD (local date, matching the existing midnight-reset semantics in lib/drawLimits.ts; installSalt is the on-device random UUID from LIVE-CONTENT-SYSTEM §7 — it never leaves the device). Deck source = getActiveDeck() from item 1.
- Anti-repeat: read recent ids from history — getHistory() in lib/storage.ts:27-30 already returns DrawHistory[]; filter the candidate pool by the last ~14 ids before applying the seeded index. (History is currently capped at 10 via pushHistory‘s .slice(0, 10) at lib/storage.ts:23; bump the cap to ≥15 for free users so the exclusion window has data — the uncapped premium history is item 8, v1.3.)
- Seed the card image too: cardImage is currently random per draw (lib/draw.ts:87-88, images 1–15 statically required in components/BLOCard.tsx:22-37). Derive it from the same date seed so “today’s card” is visually stable for shares.
- Call site: the draw flow in app/index.tsx (the 1034-line home screen) — free-tier path calls drawDaily, premium path calls draw. canDrawCard() in lib/drawLimits.ts:14-50 is untouched.
3. The Week’s Card (UI on top of item 1)
Effort: S–M (~1–1.5 days, given item 1 exists) · Unlocks: the headline promise, the share loop’s weekly asset, the load-bearing weekly-open metric.
What. One hand-written card per week (both voices), same card for everyone, free for everyone including free tier. Rendered with the provenance frame from the schema. No archive, no backlog, no streak.
Why (strategy). This is move #1 of three. It is also the measurement sensor: weekly-open gate = GETs on weekly/current.json ÷ trailing-90-day installs, ≥30% for 4 consecutive weeks by day 60 → Missing Cards launches; <15% → all paid-content work stops.
UX sketch. A quiet second surface on the home screen (app/index.tsx) — a small “This week” tile below the daily-draw area, styled like a letter rather than a draw: tap → the week’s card presented in the existing card frame (components/BLOCard.tsx / FlipCard.tsx), tone toggle respected (both voices always ship). Under the card, the provenance line in small type: “written this week, by hand” or “a card for any week like this one.” It does not consume the free daily draw, shows no dates-counted mechanics, and if the cache is empty and the device is offline, the tile simply isn’t there.
Technical approach.
- Data: getWeeklyCard() from lib/remoteContent.ts. Schema: { schemaVersion, week, provenance, card: { front: {pg, sassy}, back: {pg, sassy} }, frame } (LIVE-CONTENT-SYSTEM §2.1) — the card body mirrors CardCopy/DeckCard in lib/types.ts so BLOCard renders it unchanged.
- Presentation: reuse FlipCard.tsx + BLOCard.tsx; pick a fixed or isoWeek-seeded cardImage (1–15) so the week’s card looks identical on every device — important for the share asset doubling as the Sunday Instagram post.
- Tone: read via getTone() (lib/storage.ts:14-17). Sassy voice on the weekly card is not premium-gated (the card is a gift; gating one voice of a free card would re-litigate the paywall) — but display-only: the Sassy mode for draws stays behind the unlock exactly as getPremium() (lib/storage.ts:89-94) gates it today.
- No new storage keys beyond the content cache. Explicitly do not touch blo:streak (lib/storage.ts:32-36) — it is dead code today and the strategy kills all streak mechanics; consider deleting the key helpers in this pass.
4. Branded Share Card + Campaign QR (app/index.tsx share flow + new components/ShareCard.tsx)
Effort: S (~1–2 days) · Unlocks: the share loop — the only thing in the whole plan that attacks the install bottleneck; the Sunday Instagram asset.
What. The ViewShot capture gains a branded frame: wordmark, two-voice motif, date, App Store QR with a per-format campaign token (story/square/dark), newsletter URL in the footer. The same rendered asset is what gets posted to @boldlittleoracle every Sunday.
Why (strategy). Share infra already works — app/index.tsx:7-8 imports ViewShot + expo-sharing, handleShare at :371 captures the card (ViewShot wrapper at :543-571) with a text-share fallback via RN Share (:399-402) — but the captured image carries zero branding and no acquisition path. Every share today is a dead end. The share-loop gate needs >0 attributed installs by day 30 and a winning format identified, which requires per-format tokens baked in from day one.
UX sketch. After reveal, tapping Share (button at app/index.tsx:709 / :721) opens a small format picker — Story (9:16), Square, Dark — each a live preview. The card text sits in a framed layout: BLO wordmark top, card front/back copy center, footer strip with date, a small QR, and boldlittleoracle.com (newsletter). One extra tap versus today; default remembers the last-used format.
Technical approach.
- New components/ShareCard.tsx: an off-screen (absolutely-positioned, opacity 0) view rendered at export resolution, wrapped in its own ViewShot ref alongside the existing cardShotRef (app/index.tsx:73). Reuses BLOCard internals for the card face; adds the frame chrome.
- QR: react-native-qrcode-svg (pure JS + react-native-svg, no native module, Expo-safe). URL = App Store link with an Apple Search Ads / campaign token per format (?pt=...&ct=blo-share-story etc.). Tokens are compiled in — generated on-device, disclosed in the FAQ, no network involved.
- Weekly-card shares carry a distinct token from daily-draw shares, and the Sunday Instagram post carries its own channel token (same component, exported once by Joshua from a dev build or via a tiny Remotion/HTML twin using the same JSON — the app component is the source of truth for layout).
- Keep the existing text-share fallback path untouched.
5. Notification Overhaul (lib/notifications.ts + app/settings.tsx)
Effort: S (~1–2 days) · Unlocks: the retention that feeds the weekly-open gate; the honest Week’s Card reminder.
What. (a) User-pickable daily reminder time; (b) ~30 rotating hand-written notification lines in Obadiah’s voice; (c) deep link straight into the draw; (d) a fixed weekly scheduled local notification for the Week’s Card — default Sunday 19:00 Berlin, user-adjustable — with invitational copy (“Obadiah’s been writing. Come see.”). Local-only, no push tokens, ever.
Why (strategy). Current state is minimal: lib/notifications.ts is 21 lines — scheduleDaily(hour = 9) with one hardcoded string (“Tap for your daily card”), and app/settings.tsx:141-143 calls scheduleDaily(9, 0) with the hour literally hardcoded. The strategy’s copy rule is strict: since the fetch is foreground-only, iOS cannot guarantee the card is on-device when the notification fires, so no copy anywhere claims the card “has arrived.” The invitation copy + publish-by-Sunday-18:00 discipline makes the promise true: the card is live on the CDN before the notification fires, and the foreground fetch delivers it the moment they open.
UX sketch. Settings gains a “Reminders” section: a time picker for the daily nudge (replacing the invisible 9:00 default), and a “Sunday letter” row with its own time picker (default 19:00). Each scheduled notification shows one of ~30 rotating lines — hand-written, both registers represented, none claiming content arrival. Tapping a notification lands directly on the draw (daily) or the Week’s Card view (weekly).
Technical approach.
- Extend lib/notifications.ts: scheduleDaily(hour, minute) keeps its CALENDAR trigger (SchedulableTriggerInputTypes.CALENDAR, already used at :13); add scheduleWeekly(weekday, hour, minute) using the same trigger type with weekday. Bug to fix while here: scheduleDaily calls cancelAllScheduledNotificationsAsync() (:9) — once two schedules coexist, rescheduling one must not nuke the other. Use identifiers (blo-daily, blo-weekly) and cancel by id (cancelScheduledNotificationAsync).
- Rotating lines: a hand-written array in a new content/notification-lines.json (bundled — these are card-adjacent copy, so the AI bright line applies: every line human-written). iOS local notifications can’t rotate content server-side, so pre-schedule the next N occurrences each with a different line on every foreground open, or accept per-schedule rotation (pick line at (re)schedule time). Simplest honest v1.2: pick a random line each time the schedule is (re)created and re-create on every app open — one line per day-ish variety at zero complexity.
- Deep link: expo-router 6 is already the navigation layer. Add content: { data: { url: '/?draw=1' } } (or /weekly), and a Notifications.addNotificationResponseReceivedListener in app/_layout.tsx that routes via router.push. Persist reminder prefs as new blo: keys in lib/storage.ts.
6. Second Printing + First Printing Archive (content + one screen)
Effort: content 4–6 evenings (Joshua) + dev S (0.5–1 day) · Unlocks: the refreshed base deck without breaking “purchased content never disappears.”
What. The refreshed 72-card base deck ships bundled in v1.2 as “The Second Printing” (duplicate clusters collapsed, soft gaps filled, weakest 10 rewritten, datable idioms purged). Retired/rewritten first-printing cards remain browsable in a read-only “First Printing” archive screen for lifetime unlockers.
Why (strategy). SHADE R2’s fix: superseding cards in the draw pool is fine; silently deleting purchased cards is not. “72 hand-written cards” stays literally true; “nothing existing ever shrinks” survives its own refresh. Named openly — Obadiah’s note in release notes + FAQ.
UX sketch. Nothing changes in the draw. In Settings (or via History), lifetime unlockers see “First Printing” — a plain scrollable list of retired/rewritten cards in the current tone, each rendered read-only in the standard card frame, headed by Obadiah’s short note (“Books get second printings. So do decks. Here’s what changed and why.”). No draw button, no share pressure. Free tier does not see the screen (decided narrowly, accepted in the plan’s unresolved list).
Technical approach.
- Replace content/base-deck.json with the Second Printing (same 72-slot schema so validateDeck passes untouched); add content/first-printing.json containing only the retired/rewritten originals { id, front, back, replacedBy?, note? }.
- New route app/first-printing.tsx (expo-router file route, like app/history.tsx which already renders card lists with the static image map). Gate with getPremium() (lib/storage.ts:89-94) — note this correctly includes founding users via the grandfather path (runFoundingUserMigration, lib/storage.ts:71-81).
- Bundled, not remote: the archive is a permanence promise, so it must not depend on the CDN.
7. Entitlement Explainer Screen (dormant)
Effort: S (~0.5 day) · Unlocks: The Missing Cards launch (month 2–3) without Moonly-style entitlement blowback.
What. One plain screen in Obadiah’s voice shown before any pack purchase button: “Your lifetime unlock covers everything you’ve ever bought here, forever — this app, the 72 cards, Sassy, all of it. This is a new deck. New cards, new work. Also one price, forever.” Built in v1.2, dormant until pack #1.
Why (strategy). Entitlement confusion is a named top risk. The screen must exist before the pack does so the pack launch is a content push + SKU, not a binary release — the whole point of the remote-content module is that Missing Cards launches without review-cycle latency (the SKU itself is registered in ASC ahead of time; expo-iap’s multi-SKU support is already half-built — skus() in lib/purchase-production.ts returns a list, lib/purchases.ts is the factory in front of it).
UX sketch. Full-screen sheet, mostly type, one paragraph, two buttons: “Show me the new deck” / “Not now.” No countdowns, no badges.
Technical approach. New component modeled on components/PurchaseModal.tsx (which already handles the reason: 'draw_limit' | 'sassy_mode' bottom-sheet pattern — add nothing to it; this is a separate, calmer surface). Route or modal triggered from the future pack detail view; ships behind a constant flag until the Missing Cards manifest exists on the CDN.
v1.3 — after the gate has data
8. Reflection Note + Uncapped History (premium, on-device only)
Effort: M (~2–3 days) · Unlocks: deepens the ritual; gives the lifetime unlock a compounding data-value reason to buy; groundwork for the 2027 “Your Year, Printed” parking-lot idea.
What. After reveal, one optional text field — “what does this mean for you?” — saved with the history entry. History cap lifted for lifetime unlockers (currently hard-capped at 10 by pushHistory‘s .slice(0, 10), lib/storage.ts:23). Free tier keeps the cap. Everything stays in AsyncStorage; nothing ever leaves the device (the “no tracking” promise makes this trivially true — there is nowhere to send it).
UX sketch. Post-reveal, below the card: a single quiet line, “Add a thought — just for you.” Tap → inline field, save on dismiss. app/history.tsx entries with notes show a small marker; tap to read. A note under the field: “Notes live only on this phone.”
Technical approach. Extend DrawHistory in lib/types.ts with note?: string; pushHistory takes the premium flag to decide the cap (getPremium() again). Add an edit affordance in app/history.tsx. Migration is nil — old entries just lack the field. Consider AsyncStorage size hygiene at uncapped scale (years of daily draws ≈ trivial KBs; fine).
9. Pick-a-Card 3-Spread (included in existing lifetime — never a new gate)
Effort: M (~2–3 days) · Unlocks: a visible, demo-able premium feature for paywall screenshots (today premium only removes limits), strengthening the €6.99 conversion story.
What. Exactly one spread — situation / block / move — for lifetime unlockers, reusing the existing shuffle + flip machinery. Not a new purchase; it lands inside the existing unlock, honoring “every future improvement to any of them.”
UX sketch. On the home screen, premium users see a second, smaller action under the main draw: “Ask a fuller question.” Three face-down cards deal out (the 5-card shuffle animation in app/index.tsx already proves the motion system), labeled Situation / Block / Move; tap each to flip. Share captures the trio in the item-4 branded frame.
Technical approach. Draw three distinct cards via the anti-repeat pool from item 2 (exclude within-spread duplicates). Reuse FlipCard.tsx at reduced scale; a new app/spread.tsx route keeps index.tsx (already 1034 lines) from growing. Gate with getPremium(); free users see the affordance dimmed with the existing PurchaseModal (reason: 'sassy_mode'-style contextual trigger — add a 'spread' reason to the union in components/PurchaseModal.tsx:32).
10. Home-Screen Widget (POST-v1.3 — only if the weekly-open gate is green)
Effort: L (native target + EAS config) · Unlocks: glanceable re-engagement for the daily card and Week’s Card.
What/Why. WidgetKit widget showing today’s card back / “your card awaits,” and the Week’s Card title on Sundays. Explicitly deferred: it is the only item requiring native-module complexity beyond the current Expo setup (react-native-widget-extension or a custom config plugin + shared App Group storage), and the strategy conditions it on the weekly-open gate being green — no point building glanceable re-engagement for content nobody opens. Depends on item 2 (deterministic daily card) so the widget and app always agree on “today’s card.” Widget must render from the App Group cache only — no network entitlement needed, keeping the tracking claim clean.
Sequencing & dependency map
item 1 (remote content) ──┬─→ item 3 (Week's Card) ─→ weekly-open gate ─→ Missing Cards launch
└─→ kill switch, Second Printing remote-supersede path
item 2 (deterministic daily) ─→ item 4 (share tokens per surface) ─→ item 5 (deep links) ─→ item 10 (widget)
item 6 (Second Printing + archive) — parallel, content-bound
item 7 (explainer) — dormant, unblocks pack #1 with zero review latency
items 8–9 — v1.3, after the gate has 4+ weeks of data
v1.2 dev total: ~7–9 days (items 1–7 + the counting Worker), matching the strategy’s §7 table. The single review cycle also carries the full claim surgery (listing EN/de-DE, paywall, FAQ, review notes) — no code, but it ships in this release’s metadata or not at all.
Known code debt to clear opportunistically in v1.2: per-draw full-deck validation (lib/draw.ts:77-78), cancelAllScheduledNotificationsAsync clobbering (lib/notifications.ts:9), dead blo:streak key + helpers (lib/storage.ts:32-36 — the strategy killed streaks; delete rather than leave attractive nuisance), and app/index.tsx at 1034 lines (extract the share flow into components/ShareCard.tsx as part of item 4 rather than as a standalone refactor).