Family Wall — handoff / start-here
Updated 2026-07-18. The pick-up point for the Family Wall. Read this first, then the INDEX sections + the linked design docs. The Family Wall went from a read-only display to a deep, monetizable family hub across ~18 PRs (#1392–#1409) in one session.
What it is now
A public, token-scoped full-screen display you run on any spare screen (old iPad, wall
TV, laptop) — /family/wall/<token>. Managed at /dashboard/family/wall. No hardware to
buy; that's the whole positioning (see the compare cluster). It shows routines, calendar,
meals, shopping, pets, who's-home, notes, weather, activities — and is now interactive:
tap-to-check-off, add-from-wall, JQ voice assistant + actions, intercom, voice alarm
clocks, and sleep stories.
Trust model (unchanged, important): the wall page is unauthenticated — the token IS the
capability. Every write-from-wall route re-scopes the target to the wall's own parent
server-side; ids are only exposed on the projection when the relevant opt-in is on
(leak-guard, asserted by the golden test in family-wall-core.test.ts).
Shipped this session (feature → migration → key code)
| Feature | Migration | Backend | Frontend |
|---|---|---|---|
| On-wall editing (shopping/dinner/notes) + backgrounds | 538 (allow_editing, background_image_url) | shopping/meal/note-AddFromWall, listMyWallBackgrounds | settings picker, wall ghost-outline quick-add |
| JQ voice actions + device identity + reminders | 539 (location_kind, owner_member_id, family_reminders) | family-wall-actions.ts (pure detect), actOnWall, family-reminders.ts | wall JQ orb ask-who flow |
| Intercom (tap-to-talk) + quiet hours | 540 (allow_intercom, quiet_start/end, family_intercom_messages) | family-intercom.ts + -core.ts | /dashboard/family/intercom, wall inbox banner |
| Voice alarm clocks (Plus) | 541 (family_alarms) | family-alarms.ts | /dashboard/family/alarms, wall wake banner |
| Sleep stories on the wall (Plus) | 542 (show_sleep_stories) | getSleepStoriesForWall (reuses sleep-story-gen.ts) | wall 🌙 shelf + player + sleep timer |
| "Add me" self-member | none (reuses linked_user_id) | POST /members/self, is_self | family page "Add me" + You badge |
| Member inline-edit + card polish | none | PATCH /members/:id (existing) | family page ✏️ edit, CARD_ACTION |
| "Invite to the wall" join flow | none | reuses member create + JQ-bridge family invite | InviteToFamilyModal |
| New soundscapes (study/dinner/focus) | none (enum) | AMBIENT_TRACKS | wall startMusic() generative engine |
| Settings redesign + curated backgrounds | none | — | sectioned settings, /public/wall-backgrounds/* presets |
| Sample-screen generator + gallery + OG | none | — | lib/family-wall-samples.ts, SampleWall, /family-wall/examples, opengraph-image.tsx |
| Compare / SEO cluster | none | — | lib/family-wall-compare.ts, /compare/family-wall[/<slug>] |
| Plus entitlement gate | none (reuses subscriptions) | family-wall-plus.ts | settings upsell banner + ✨Plus badges |
Migrations 538–542 are all applied to prod. (MCP Supabase ≠ prod — the user applies
file migrations to prod manually; every wall PR needed the migration applied before the
backend deploy, or the new SELECT columns 500 the whole wall. This bit us repeatedly —
always surface the SQL and gate the merge on it.)
Monetization — the gate is BUILT but OFF
family-wall-plus.ts reuses the shared subscriptions table (plan_key='family_wall_plus').
requireFamilyWallPlus() is a no-op until FAMILY_WALL_PLUS_ENFORCED=true, so nothing
is paywalled today. Enforced at: createWallDisplay (2nd+ wall), askWall/actOnWall
(Ask JQ), sendIntercom, updateWallDisplay (show_sleep_stories), alarm create/enable →
HTTP 402. GET /api/family/wall/plus-status drives the settings upsell.
Stripe checkout — ✅ BUILT (mirrors Graphene+/write-cafe-pro). POST /api/payment/family-wall-plus/checkout
({ interval: 'monthly'|'annual' }) → Stripe Checkout; the webhook's priceToPlanKey maps
STRIPE_PRICE_FAMILY_WALL_PLUS / _ANNUAL → family_wall_plus and upserts the subscriptions
row on checkout.session.completed (same path every tier uses — cancel/update ride the existing
customer.subscription.* handlers). GET /api/payment/family-wall-plus/config gates the CTA so it
hides until the price envs exist. Settings CTA + ?plus=1 thank-you in wall/page.tsx.
Cost-point gates — ✅ BUILT. Cloned-voice wall messages (applyMessageAudio →
sayInUserVoice) now skip the paid clone render for non-Plus owners gracefully — the message
still shows and reads aloud in free browser TTS, with a plus_required voice_warning, no 402, no
ElevenLabs spend. Kids'-chat enablement (updateKidChat when enabled:true) is gated → 402
{upgrade, feature} (surfaced on the kids page error banner). Both no-op until enforcement is on.
To actually launch Plus — only ops steps remain (full runbook + grandfather SQL: FAMILY_WALL_PLUS_LAUNCH.md):
- Create the two Stripe prices (~$4.99/mo · $39/yr) and set
STRIPE_PRICE_FAMILY_WALL_PLUSSTRIPE_PRICE_FAMILY_WALL_PLUS_ANNUALon the Railway backend. (Checkout endpoints 503 until then; the config probe keeps the button hidden — no user-visible breakage.)
- Grandfather existing users generously (the runbook has one-user + all-wall-owners SQL).
- Set
FAMILY_WALL_PLUS_ENFORCED=true.
Multi-tier cancel — ✅ FIXED. The self-serve cancel flow
(subscription-cancellation.ts) is now plan_key-aware: every action
(cancelSubscription / applyRetentionOffer / switchToAnnual / logKeptWithoutOffer /
getCancellationConfig) takes an optional planKey, and the /api/payment/cancellation/* routes
read plan_key from the body/query. A user holding one active sub still cancels with no
plan_key (back-compat); a user holding two (e.g. Graphene+ and Family Wall Plus) must pass
plan_key — omitting it returns HTTP 409 {plan_keys} instead of silently guessing. Interval
detection + annual-switch are now generic per plan (planPriceIds). The CancellationModal takes a
planKey prop (defaults graphene_plus); a future Family Wall Plus manage-screen passes
planKey='family_wall_plus'.
See FAMILY_WALL_MONETIZATION.md for the free/paid split + pricing rationale.
Next builds (recommended order)
Stripe checkout + gate the two cost points✅ built — to flip the gate, only ops remain: create the Stripe prices + set the envs, grandfather early users, thenFAMILY_WALL_PLUS_ENFORCED=true(above).- Bedtime stories in your voice (sleep-stories Phase 2) — re-narrate a
story_episodeschapter in the parent's cloned voice (long-formnarration.tspipeline, not the short-clipsayInUserVoice). Cache per (story, voice); cap length.FAMILY_WALL_SLEEP_STORIES.md. - Bundle Graphene+ into Plus — shared/derived entitlement so catalog access is coherent.
- Captured-but-unbuilt: view-only guest account scope + post-accept access change
(
FAMILY_WALL_JOIN_INVITE.md); the photo-frame layer (still COUNSEL-GATED for minors,FAMILY_PHOTO_FRAME.md); more compare competitors (one lib entry each); the intercom audio-clip recording (audio_urlcolumn reserved).
Landmines / working notes
- Migration-before-deploy — see above. Non-negotiable for any wall PR that adds a column.
- Wall-file merge coordination — many features touch
family/wall/[token]/page.tsx,family-wall.ts,family-wall-core.ts. Land one wall PR (apply its migration, merge) before building the next same-file feature, or you stack conflicting PRs (repo squash-merge handles stacks painfully — see CLAUDE.md). mainis protected — squash-merge via PR only; branch first.- Generative audio is asset-free by design — soundscapes + alarm tones + JQ speech are all WebAudio/speechSynthesis (no hosted files/licensing). Keep new audio in that style.
- Sample generator is seeded/deterministic — no
Math.random/Datein it (stable SSR, no hydration mismatch).
Map of docs + memories
Docs: FAMILY_WALL_MONETIZATION.md, FAMILY_WALL_SLEEP_STORIES.md, FAMILY_WALL_JOIN_INVITE.md,
FAMILY_WALL_JQ_ACTIONS.md, FAMILY_WALL_INTERCOM.md, FAMILY_PHOTO_FRAME.md (counsel-gated).
INDEX.md has a section per feature with exact files/endpoints. Memories: project_family_frame,
project_family_wall_monetization, project_family_wall_jq_actions, project_family_wall_intercom,
project_family_self_member, project_family_wall_join_invite, project_sophia_licensing_gate.