Agent Skills: Cinema Voice Architect

|

UncategorizedID: Spectaculous-Code/raamattu-nyt/cinema-voice-architect

Install this agent skill to your local

pnpm dlx add-skill https://github.com/Spectaculous-Code/raamattu-nyt/tree/HEAD/.claude/skills/cinema-voice-architect

Skill Files

Browse the full folder contents for cinema-voice-architect.

Download Skill

Loading file tree…

.claude/skills/cinema-voice-architect/SKILL.md

Skill Metadata

Name
cinema-voice-architect
Description
|

Cinema Voice Architect

Expert skill for Cinema Mode and audio/voice implementation.

Quick Reference

Base Layer (CinemaShell)

| Component | Location | |-----------|----------| | CinemaShell | src/components/cinema/CinemaShell.tsx | | Cinema Background | src/components/cinema/CinemaBackground.tsx | | Background Music Picker | src/components/cinema/BackgroundMusicPicker.tsx | | Background Visual Picker | src/components/cinema/BackgroundVisualPicker.tsx | | Cinema Preferences Hook | src/hooks/useCinemaPreferences.ts | | Cinema Fullscreen Hook | src/hooks/useCinemaFullscreen.ts | | Cinema Audio Hook | src/hooks/useCinemaAudio.ts | | CinemaShellContext (shell ctx as React context for shell-less apps) | src/components/cinema/CinemaShellContext.ts |

Cinema OS / Command Center (orchestration layer)

| Component / Module | Location | |-----------|----------| | Design doc (read first) | Docs/cinema/CINEMA-OS.md | | Types (Intent / CinemaApp / CinemaNav) | src/cinema-os/types.ts | | Navigation reducer (stack) | src/cinema-os/navigation.ts | | Registry + deep-link translation | src/cinema-os/registry.ts | | App registration (all apps) | src/cinema-os/cinemaApps.ts | | CinemaOSProvider (stack + history + 2-level back) | src/cinema-os/CinemaOSProvider.tsx | | CinemaHost (the ONE persistent shell) | src/cinema-os/CinemaHost.tsx | | Contexts + useCinemaNav / useCinemaBackHandler | src/cinema-os/context.ts | | useCinemaShell (re-export) | src/cinema-os/useCinemaShell.ts | | Session history (localStorage) | src/cinema-os/history.ts | | Route element (/cinema-os/*) | src/cinema-os/CinemaOSRoot.tsx | | Shared settings/music sheets | src/cinema-os/CinemaOSSheets.tsx | | Shared settings-sheet wiring (single source for CinemaOSSheets AND CinemaReaderScreen). βš™ sheet = bg+textsize+completion+speed; Raamatun versio + lukuÀÀni (CinemaVersionVoiceSection) are split to the πŸ”Š music popover (audio side) | src/components/cinema/useCinemaSettingsProps.ts | | Cinema OS help overlay (i-icon β†’ "what is Cinema OS") | src/cinema-os/CinemaOSHelpOverlay.tsx | | CinemaOSNavCluster (top-left Back + Command Center pill; shared by CinemaOSChrome + ownsChrome apps like the prayer room) | src/cinema-os/CinemaOSNavCluster.tsx | | Apps: command-center / search / topic / question / questions / curated / reading / reading-plan / prayer-room / discipleship / summary | src/cinema-os/apps/*.tsx | | PrayerRoomApp (multiplayer Rukoushuone in-shell; ownsChrome β†’ OS chrome hidden) | src/cinema-os/apps/PrayerRoomApp.tsx | | DiscipleshipCinemaApp (full TΓ€nÀÀn Discipleship-tila in-shell; dual-mode CinemaReaderScreen + ownsChrome) | src/cinema-os/apps/DiscipleshipCinemaApp.tsx | | SummaryCinemaApp (Kooste played as verse cinema; dual-mode CinemaReaderScreen mode="summaryItems" + ownsChrome) | src/cinema-os/apps/SummaryCinemaApp.tsx | | Launch entry (TΓ€nÀÀn top) | src/pages/tanaan-page/CinemaOSEntryCard.tsx |

Content Layer (CinemaReaderScreen)

| Component | Location | |-----------|----------| | CinemaReaderScreen | src/features/cinema/CinemaReaderScreen.tsx | | Bible Audio Hook | src/hooks/useBibleAudio.ts | | Auto-Advance Hook | src/hooks/useAutoAdvance.ts | | Chapter Bundle Hook | src/hooks/useChapterBundle.tsx | | Audio Sync | src/lib/cinemaAudioSync.ts | | Audio Estimation | src/lib/audioEstimation.ts | | Cinema Types | src/types/cinema.ts |

Discipleship Layer

| Component | Location | |-----------|----------| | Discipleship Orchestration | src/hooks/useDiscipleshipOrchestration.ts | | Discipleship Utils | src/components/cinema/discipleshipUtils.tsx | | Discipleship TaskSelector | src/components/cinema/DiscipleshipTaskSelector.tsx | | Discipleship InlineTask | src/components/cinema/DiscipleshipInlineTask.tsx | | Discipleship VerseBar | src/components/cinema/DiscipleshipVerseBar.tsx | | Discipleship Pomodoro | src/components/cinema/DiscipleshipPomodoroButton.tsx | | Discipleship Transition | src/components/cinema/DiscipleshipTransitionOverlay.tsx | | Verse Memory Quiz | src/components/practice/VerseMemoryQuiz.tsx | | Discipleship Tasks Hook | src/hooks/useDiscipleshipTasks.ts | | Nyt Summary Hook | src/hooks/useNytSummary.ts | | Reading Plan Quiz Hook | src/hooks/useReadingPlanQuiz.ts | | Smart Verse Selection | src/hooks/useSmartVerseSelection.ts |

Program Mode (Curated Plans)

| Component | Location | |-----------|----------| | CuratedPlanCinema (wrapper + flow router) | src/components/cinema/CuratedPlanCinema.tsx | | CuratedPlanFlow (continuous / linear) | src/components/cinema/CuratedPlanFlow.tsx | | ScrollingPlanFlow (scrolling) | src/components/cinema/ScrollingPlanFlow.tsx | | MiniTaskView (inside CuratedPlanFlow) | src/components/cinema/MiniTaskView.tsx | | Curated Plan Cinema Hook | packages/shared-practices/src/hooks/useCuratedPlanCinema.ts | | Shared types (GrandPlanProgressionType, MiniTaskContentConfig, MiniTaskRoute) | packages/shared-practices/src/types.ts | | Shared snap-scroll mechanics (InfoFlow + ScrollingPlanFlow) | src/hooks/useSnapScrollSteps.ts |

Info Cinema (Q&A / Topic Info-palaset)

| Component / Hook | Location | |-----------|----------| | InfoCinema (wrapper: CinemaShell + sheets) | src/components/cinema/InfoCinema.tsx | | InfoFlow (one-page snap-scroll, card per block) | src/components/cinema/InfoFlow.tsx | | InfoView (variant="scroll" = one block card) | src/components/cinema/InfoView.tsx | | CinemaQuestionWrapUp (Q&A finalSlide) | src/components/cinema/CinemaQuestionWrapUp.tsx | | QuestionAnswersCinema (the Film CTA) | src/pages/question-detail/QuestionAnswersCinema.tsx | | CinemaWrapUpRelatedQuestions (shared "LiittyvΓ€t kysymykset" list) | src/components/cinema/CinemaWrapUpRelatedQuestions.tsx |

Topic Cinema (aihe-cinema)

| Component / Hook | Location | |-----------|----------| | TopicCinema (wrapper: CinemaShell + sheets, mirrors InfoCinema) | src/components/cinema/TopicCinema.tsx | | TopicFlow (snap-scroll over heterogeneous TopicCinemaStep[]) | src/components/cinema/topic/TopicFlow.tsx | | TopicCinemaWrapUp (topic finalSlide) | src/components/cinema/topic/TopicCinemaWrapUp.tsx | | Card views (explanation / strongs / verses / highlights) | src/components/cinema/topic/Topic*View.tsx | | Step composer hook | src/hooks/cinema/useTopicCinemaSteps.ts | | TopicCinemaButton (the Film CTA on /aihe/<slug> + haku/aihekortti + preview) | src/pages/topic-page/TopicCinemaButton.tsx | | CinemaVerseActions (per-verse: pohdittavaksi/koosteeseen/muistiinpano + open) | src/components/cinema/CinemaVerseActions.tsx | | TopicChapterReader (kevyt luku-cinema: 6-verse scroll, prev/next chapter, X) | src/components/cinema/topic/TopicChapterReader.tsx | | TopicVerseCinema (1-verse carousel via @raamattu-nyt/cinema-reader embedded) | src/components/cinema/topic/TopicVerseCinema.tsx | | CinemaVersePreviewPopup (Haku>Jakeet result β†’ verse-in-chapter popup, per-verse jae-cinema) | src/components/cinema/CinemaVersePreviewPopup.tsx |

Topic cinema is a 4th CinemaShell consumer. Unlike Info Cinema it does NOT reuse InfoFlow (which is InfoBlock[]-only) β€” it has its own TopicFlow over a discriminated TopicCinemaStep[] (explanation | info | strongs | verses | highlights), reusing useSnapScrollSteps, CinemaShell, and InfoView variant="scroll" for the info-block cards. Background music only, no verse-TTS. Steps composed in useTopicCinemaSteps from existing topic hooks (useTopicData, useTopicInfoBlocks, useUserHighlightsForTopic, useAnchorQuestions, useQuestionsForInfoBlocks).

Internal view stack (one shell, three layers). TopicCinema keeps a SINGLE CinemaShell and toggles layers rendered on top of each other (music continues): flow (TopicFlow) β†’ chapter (TopicChapterReader, lightweight 6-verse scroll of a chapter via useChapterBundle, prev/next chapter + X) β†’ verseCinema (TopicVerseCinema, the real 1-verse-at-a-time carousel = @raamattu-nyt/cinema-reader <CinemaReader embedded> β€” NO own shell/fullscreen, no double music). Opened from per-verse buttons: a verse card's "Lue" β†’ chapter reader at that verse; a chapter verse's "Cinema" β†’ verse carousel at that verse.

Stacking-context / portal gotcha. TopicCinema is rendered via createPortal(…, document.body). Reason: TopicCinemaButton mounts inside sticky headers (TopicPageHeader z-50, SearchPageHeader z-40, TopicPreviewHeader) which create their own stacking context β€” so CinemaShell's fixed inset-0 z-[9999] was scoped to the header and the app sidebar (fixed z-[60] at page root, a higher root-level context) painted over the cinema's left edge. Portaling to body escapes the header context. Any new cinema launched from inside a position:sticky/relative + z-index ancestor needs the same portal (the reading cinema isn't, so it never hit this).

ESC-pino gotcha (load-bearing). Browser ESC exits fullscreen, and CinemaShell auto-closes on fullscreen-exit. So TopicCinema routes the shell's onClose to a popOrClose guard that pops one layer (verseCinema→chapter→flow) and only truly closes at flow. A capture-phase keydown handler covers ESC when NOT in browser fullscreen (!document.fullscreenElement) so the 2nd/3rd ESC keeps popping. Never make the layers separate CinemaReaderScreen/CinemaShell instances — that yields double-fullscreen + double-music + the auto-close race.

Per-verse actions (CinemaVerseActions, shared by topic verse cards + chapter reader): pohdittavaksi (upsert_highlight_full→toggle_verse_interest), koosteeseen (useNytSummary), muistiinpano (saveVerseNote) — modeled on DiscipleshipVerseBar (don't edit that) — plus a context 4th button (Lue / Cinema). Pohdittavaksi+note need verse_id (from get_verses_by_refs / BundleVerse.id) + login; they hide otherwise. The 4th open button shows ONLY when onOpen is passed. TopicChapterReader's onOpenVerseCinema is optional → omitting it hides the per-verse Film/"Cinema" button: ReadingPlanCinemaApp does this (it's opened FROM jae-cinema, so jae→luku→jae would be a loop). TopicCinema / TopicCinemaApp / ReadingCinemaApp keep it (there the verse carousel is the primary path).

TopicChapterReader picker gotchas (load-bearing). (1) Stacking: the top bar (holding the showBibleNav BibleNavRow book/chapter/version picker) and the verse list are siblings under the reader root (zIndex:3). They must NOT share a z β€” the top bar is relative z-30, the verse list/bottom bar stay z-10, so the BibleNavRow dropdown paints ABOVE the verses. With equal z the later-DOM verse list painted over the dropdown β†’ it "showed through" AND intercepted clicks (picker unselectable). (2) Verse numbers are a baseline <span> at 1.05em (NOT a tiny <sup>) β€” slightly larger than the verse text. (3) BibleNavRow has arrow+Enter+Esc keyboard nav inside an open panel: a capture-phase window keydown (only while openPanel set) + stopPropagation so it preempts the chapter reader's Left/Right chapter-nav; grid-aware (navCols matches the grid classes), active item ring + scrollIntoView. The three select handlers are useCallback (used as effect deps).

Verse-range = one block, one menu. A topic reference can be a range (Joh.3:1-8). useTopicCinemaSteps builds VersesStep.groups: VerseGroup[] (one group per reference, keyed osisStart / osisStart-osisEnd β€” same key useTopicData uses for its verses Map), NOT a flat verse list. TopicVersesView renders ONE block per group (verses concatenated, per-verse number as <sup> only when range) with ONE CinemaVerseActions. Range-aware semantics via verseIds (all verse UUIDs β†’ pohdittavaksi marks the whole range) + endVerse (koosteeseen adds the range Joh.3:1-8 as one item; note appends [koskee N-M jakeita] and attaches to the FIRST verse; Lue starts the chapter reader at the first verse). addToNytSummary(book, chapter, verseNumber, versionCode, endVerse?).

Verse ORDER must match the page (one ordering, three surfaces). The aihe-cinema verse cards, the /aihe topic-page verses tab, and the search TopicPreview reference list must all show references in the SAME order. The canonical order is sortReferences in useTopicData.ts: relevance_score DESC β†’ NT(new) first β†’ canonical book_order β†’ chapter β†’ verse. The cinema + topic page both go through useTopicData (same sorted references array). The search preview is the odd one out β€” it loads refs via the get_topic_preview(p_slug, …) RPC, which must carry the identical ORDER BY tie-break (relevance alone is NOT enough; equal-relevance refs otherwise return in arbitrary DB order and the preview visibly diverges from the cinema). If "cinema order β‰  page order" is reported, check whether the reported "page" is the search preview and whether that RPC's reference ORDER BY still matches sortReferences.

Language: selectTopicDescription(topic, isFinnish) picks FI/EN description fields; verse refs via formatOsisReference(osis, null, isFinnish). get_verses_by_refs returns verse_id + book_code (threaded into VerseData). Book label in the chapter/verse cinema: book may be a lowercase db code ("john") β€” normalize with getOsisFromAny(book) β†’ OSIS before getOsisBookNameFinnish / getEnglishBookNameFromOsis. get_chapter_bundle (useChapterBundle) gotcha (load-bearing): its p_book_name resolves a DB book NAME, NOT the OSIS code β€” numbered-book OSIS codes (1Cor/2Thess) return "Book not found" β†’ empty chapter (single books like John/Matt happen to resolve). So TopicChapterReader, TopicVerseCinema, and CinemaVersePreviewPopup pass getCanonicalDbBookName(book) to useChapterBundle (OSIS/code β†’ "1 Corinthians"). They MUST all normalize identically or the shared bundle cache key diverges and TopicVerseCinema's lazy start-verse init misses the cache (β†’ starts at verse 0). Keep the raw book (OSIS) for labels + CinemaVerseActions (koosteeseen localization).

TopicVerseCinema is self-fetching (useChapterBundle, cache shared with the chapter reader). Auto-advance is NOT the engine β€” the CinemaReader GSAP engine's internal auto-play timer is DISABLED in this project ("verse advancement handled by app-level useAutoAdvance"). So playing={true} alone does nothing; you MUST drive a controlled currentIndex with useAutoAdvance (@/hooks/useAutoAdvance, cue-timed: onAdvance=setCurrentIndex, onCompleteβ†’completion overlay) exactly like CinemaReaderScreen. useVerseCues (no audio β†’ estimated cues) feeds both the cueDurations (segmented progress bar) and the auto-advance timing. onIndexChange keeps the controlled index in sync with manual nav (progress click / drag / arrows). Audio + manual nav (load-bearing): with Bible TTS playing, manual nav must go through onManualNavigation so useCinemaAudioSync enters manual mode (suppresses audio-follow) and immediately seeks the audio to that verse; otherwise the package's syncToAudioTime yanks the highlight straight back ("snap-back"). The package fires onManualNavigation from BOTH handlePrev/handleNext AND handleSeek (progress click/drag) β€” if a future progress handler skips it, audio-chapter clicks regress. The settle timer only LIFTS suppression; it must not re-seek (that would rewind the just-started verse). Compact controls: compact + cueDurations/verseNumbers (segmented bar, like the reading cinema) + onOpenSettings/onOpenMusic wired to TopicCinema's sheets. Per-chapter reset/replay = just setCurrentIndex(0) (controlled index β†’ engine seeks; no key remount needed β€” the engine already remounts on verses change). Start-verse gotcha (fixed in package): both GSAP engines' mount force offset = 0 inside a double-rAF after measurement, so the carousel always started at verse 0 β€” a synchronous post-mount engine.seek runs before that rAF/measurement and is wiped. Fix: CinemaEngineMountOptions.initialIndex (applied inside the rAF after measure, both vertical + horizontal); CinemaReader passes initialIndex: indexRef.current. So the CONSUMER must have the correct controlled currentIndex AT MOUNT β€” TopicVerseCinema lazy-inits currentIndex from the already-cached bundle (useState(() => …)), not via a post-mount effect (which would lose to the engine's mount reset). This also finally makes CinemaReaderScreen's initialVerseIndex honor non-zero starts. Compact music icon: the package ControlBar only shows the speaker icon when onOpenMusic && (onToggleMusic || onMusicVolumeChange) β€” pass the shell-ctx music handles (onToggleMusic etc.) from TopicCinema, not just onOpenMusic. The music sheet itself is the shared CinemaMusicPopover (no new component). Chapter reader arrow keys are gated by active (off when the verse carousel is layered on top).

Shared wrap-up rule: the two finalSlide cards (CinemaQuestionWrapUp, TopicCinemaWrapUp) intentionally share ONLY the related-questions list via CinemaWrapUpRelatedQuestions (each builds its own deduped CinemaWrapUpRelatedRow[] then renders it). The cards are NOT merged β€” their feedback (QuestionFeedbackBar vs generic useFeedback thumbs+text), primary action (next-question vs free-text search), and navigation model (callbacks for Q&A's contentKey in-cinema transition vs direct navigate+close) differ fundamentally. Merging would need conditional slots > the duplication removed. Add a new shared primitive only when markup is byte-identical.

TopicCinemaWrapUp continuation paths (reading plan + prayer room). The wrap-up's "PΓ€ivΓ€n lukusuunnitelma" opens ReadingPlanChooserOverlay (components/cinema/topic/) β€” a createPortal(…, document.body) popup (z above the cinema's z-[9999]) listing today's reading plans (useDiscipleshipTasks() β†’ type === "reading_plan", completed=green) with Enter = first unread, and a bottom "Lopeta" button. When EVERY plan is read (allDone), focus moves to Lopeta and firstUnread is undefined β†’ plain ENTER closes (ends the walkthrough; in ReadingPlanCinemaApp onClose = nav.back) instead of re-opening a done plan. Picking a plan closes the cinema and navigates /tanaan?startReadingPlanId=<task.id>. The chooser MUST portal to getCinemaPortalContainer() ?? document.body, NOT bare document.body β€” CinemaShell is in native fullscreen, so a body-portaled overlay renders OUTSIDE the fullscreen element and is invisible ("button does nothing"). Same fullscreen-portal trap the mobile sheets solve; navigate-only buttons (prayer room) are unaffected because they exit fullscreen. useTanaanPageState consumes that param (once data loads, ref-guarded strip like ?startCinema), resolves it against cinemaModalTasks (SAME id space), and opens the reading-plan cinema with initialTask (autoStart off β†’ starts that plan, then DiscipleshipTaskSelector shows the rest). "Avaa rukoushuone" opens today's room directly via resolveTodayPrayerRoomConfig(savedPrayerRooms) (extracted from useTodayPrayerRoom) β†’ navigate("/rukoushuone", { state: { config } }) (PrayerRoomPage opens straight into "room" view). Do NOT revert these to bare navigate("/tanaan") / navigate("/rukoushuone") β€” the latter opens an empty setup (effectively a new room).

Prayer Room (Rukoushuone)

| Component / Hook | Location | |-----------|----------| | PrayerRoomScreen | src/features/prayer-room/PrayerRoomScreen.tsx | | PrayerRoomPage (route) | src/pages/PrayerRoomPage.tsx | | PrayerRoomSetup | src/features/prayer-room/PrayerRoomSetup.tsx | | PrayerRoomContent | src/features/prayer-room/PrayerRoomContent.tsx | | PrayerRoomHeader | src/features/prayer-room/PrayerRoomHeader.tsx | | PrayerRoomBottomBar | src/features/prayer-room/PrayerRoomBottomBar.tsx | | PrayerRoomCalendarRail (left weekday rail) | src/features/prayer-room/PrayerRoomCalendarRail.tsx | | useCalendarRailModel | src/features/prayer-room/hooks/useCalendarRailModel.ts | | PrayerRoomInviteDialog | src/features/prayer-room/PrayerRoomInviteDialog.tsx | | CalendarPrayerBrowser | src/features/prayer-room/CalendarPrayerBrowser.tsx | | Prayer Room Types | src/features/prayer-room/types.ts | | useTodayPrayerRoom | src/features/prayer-room/useTodayPrayerRoom.ts | | usePrayerRooms | src/hooks/usePrayerRooms.ts | | usePrayerRoomSync (Realtime) | src/hooks/usePrayerRoomSync.ts | | usePrayerRoomInvitations | src/hooks/usePrayerRoomInvitations.ts | | usePushToTalk | src/hooks/usePushToTalk.ts | | useWebRTCAudio | src/hooks/useWebRTCAudio.ts |

Audio Pipeline

| Component | Location | |-----------|----------| | Audio Service | src/lib/audioService.ts | | ElevenLabs Voices | src/lib/elevenLabsVoices.ts | | Audio Generation | supabase/functions/generate-audio/index.ts |

Pages

| Component | Location | |-----------|----------| | Tanaan Page | src/pages/TanaanPage.tsx | | Discipleship Landing | src/pages/DiscipleshipLandingPage.tsx | | Spiritual Path Faith | src/pages/SpiritualPathFaithPage.tsx | | Daily Reading View | src/components/reading-plans/DailyReadingView.tsx |

Architecture Overview

CinemaShell (base layer ~438 lines)
β”‚  Fullscreen, background visuals (Ken Burns), background music,
β”‚  preferences, visual/music pickers, keyboard shortcuts (B/N/V/M)
β”‚  Props: isOpen, onClose, title?, hideControls?, dimControls?
β”‚  Exports: CinemaShellContext (render props for children)
β”‚
β”œβ”€β”€ CinemaReaderScreen (content layer ~1033 lines)
β”‚   β”‚  Verse fetching/mapping, Bible audio, auto-advance, audio sync,
β”‚   β”‚  completion overlay. Uses CinemaShell as wrapper via render props.
β”‚   β”‚  Modes: "chapter" | "verseList" | "summaryItems"
β”‚   β”‚
β”‚   └── useDiscipleshipOrchestration (task queue ~792 lines)
β”‚       β”‚  Task queue, completion persistence, quiz insertion,
β”‚       β”‚  transition overlays, verse bar, kooste, prayer messages
β”‚       β”‚
β”‚       └── Overlay Components
β”‚           β”œβ”€β”€ DiscipleshipTaskSelector (pick tasks)
β”‚           β”œβ”€β”€ DiscipleshipInlineTask (prayer/practice/quiz)
β”‚           β”œβ”€β”€ DiscipleshipTransitionOverlay (between tasks)
β”‚           └── DiscipleshipVerseBar (note/share on pause)
β”‚
β”œβ”€β”€ CuratedPlanCinema (program mode ~66 lines)
β”‚   β”‚  Wrapper: loads grand plan mini_tasks + progression_type via useCuratedPlanCinema.
β”‚   β”‚  CinemaShell with hideControls. Routes to flow based on progression_type:
β”‚   β”‚    progressionType === "scrolling" ? ScrollingPlanFlow : CuratedPlanFlow
β”‚   β”‚  On all-tasks-complete: calls complete_grand_plan RPC + invalidates ["grand-plans"].
β”‚   β”‚
β”‚   β”œβ”€β”€ CuratedPlanFlow (continuous / linear ~133 lines)
β”‚   β”‚   β”‚  One mini-task card at a time, AnimatePresence crossfade.
β”‚   β”‚   β”‚  Scrim bg-black/40 + card bg-black/60 backdrop-blur-md.
β”‚   β”‚   β”‚  completedIds Set; backward jumps un-complete the target.
β”‚   β”‚   β”‚  Choice routing: next | jump_to_sort_order.
β”‚   β”‚   β”‚
β”‚   β”‚   └── MiniTaskView (step UI ~223 lines)
β”‚   β”‚          SVG circular countdown timer, pause via DiscipleshipPomodoroButton,
β”‚   β”‚          JATKA hidden when choices exist (choice-to-advance).
β”‚   β”‚
β”‚   └── ScrollingPlanFlow (scrolling ~336 lines)
β”‚       Snap-scroll all steps on one page, numbered progress pills (click-to-jump),
β”‚       IntersectionObserver (threshold 0.4) tracks active step,
β”‚       per-step auto-advance timer (duration_seconds, default 30s),
β”‚       user-scroll detection pauses auto-advance 1.5s.
β”‚       Inline StepContent (NOT MiniTaskView) β€” no countdown ring, no completedIds.
β”‚       See references/curated-plans.md for full details.
β”‚
β”œβ”€β”€ InfoCinema (Q&A / topic info-palaset ~121 lines)
β”‚   β”‚  CinemaShell with hideControls; renders InfoFlow from parent-loaded
β”‚   β”‚  InfoBlocks. Mirrors CuratedPlanCinema. See references/info-cinema.md.
β”‚   β”‚
β”‚   └── InfoFlow (one-page snap-scroll, card per block ~350 lines)
β”‚          Uses useSnapScrollSteps (SHARED mechanics). Left progress rail +
β”‚          numbered pills, per-card auto-advance, prayer-room-style bottom bar.
β”‚          Optional finalSlide (CinemaQuestionWrapUp). key={contentKey} remount.
β”‚
β”œβ”€β”€ PrayerRoomScreen (prayer room consumer ~499 lines)
β”‚   β”‚  CinemaShell with hideControls; owns prayer/verse state, realtime sync,
β”‚   β”‚  PTT, WebRTC audio mesh, invitations. Route: /rukoushuone.
β”‚   β”‚  See references/prayer-room.md for full details.
β”‚   β”‚
β”‚   └── Parallel runtime systems (scoped to config.id)
β”‚       β”œβ”€β”€ usePrayerRoomSync     (presence + host state broadcast, debounced 100ms)
β”‚       β”œβ”€β”€ usePushToTalk         (single-speaker floor + FIFO hand queue)
β”‚       └── useWebRTCAudio        (STUN-only audio mesh, 2–5 users)
β”‚
└── Future: Meditation, etc.
    └── CinemaShell + custom content

Audio Pipeline:
  generate-audio Edge Function β†’ ElevenLabs API (with timestamps)
  β†’ audio_assets table (hash-cached) β†’ audio_cues table (verse timing)

Audio Split

CinemaShell and CinemaReaderScreen both use useCinemaAudio but for different purposes:

  • CinemaShell: bibleAudioUrl: null (music-only)
  • CinemaReaderScreen: backgroundMusicUrl: null (Bible audio-only, music from CinemaShell context)

Cinema OS / Command Center

Orchestration layer that wraps the individual cinemas in one persistent CinemaShell so the user moves Topic β†’ Search β†’ Question β†’ Curated as a single continuous session (music/background never reset β€” "Γ€lΓ€ poistu Cinemasta"). Route /cinema-os (entry card atop the TΓ€nÀÀn page). Read Docs/cinema/CINEMA-OS.md first.

/cinema-os β†’ CinemaOSProvider (stack reducer + history + localStorage)
             └─ CinemaHost (mounted ONCE)
                └─ CinemaPreferencesProvider (hoisted, shared once)
                   └─ <CinemaShell> (persistent: music / bg / fullscreen)
                      └─ CinemaShellContext.Provider
                         └─ ActiveCinemaApp = registry[topIntent.type].Component
                              command-center Β· search Β· topic Β· question Β· curated

Three separated concerns: Intent (serializable {type,payload}) β†’ Registry (intent.type β†’ CinemaApp, declarative, no host branches) β†’ Navigation (stack reducer + history). Apps are shell-less content that consume useCinemaShell(); they never render a CinemaShell.

Search β†’ Topic chaining: SearchCinemaApp picks a topic β†’ nav.launch({type:"topic", payload:{slug}}) β†’ OS pushes Topic Cinema; back returns to Search. QuestionCinemaApp's info-block CTA β†’ nav.launch({type:"curated", ...}). Apps special-case nothing β€” they emit intents.

Invariants (load-bearing β€” don't break these)

  • Apps are shell-less. Consume useCinemaShell() (from components/cinema/CinemaShellContext). The standalone wrappers (TopicCinema, InfoCinema, CuratedPlanCinema) are LEFT UNTOUCHED for their existing launch buttons; the OS apps (src/cinema-os/apps/*) are parallel shell-less mirrors. Don't merge them yet.
  • Intent.payload = plain serializable data only (slug/id/query) β€” never functions/objects. One rule β†’ history + localStorage + deep-link + (Phase 2) DB are the same representation. Derive auth/version/i18n INSIDE the app, not in the payload.
  • Two-level back: apps with internal layers (Topic's flowβ†’chapterβ†’verse) register useCinemaBackHandler(fn); the host's nav.back() (and ESC, shell X, fullscreen-exit) runs interceptors LIFO first, then pops the OS stack. Generalizes TopicCinema's old popOrClose. Question/Curated have NO internal layers β†’ no interceptor.
  • Only the top frame is mounted (Phase 1). nav.replace PRESERVES the frame key (in-place payload update, no remount) β€” Search uses it to persist query/tab so the topic round-trip restores them. Provider URL-sync is path-based; replace does NOT record history. Keep-alive is a Phase 2 opt-in (CinemaApp.keepAlive, default false, never Reader/PrayerRoom).
  • Only "destinations" are intents. Topic's chapter/verse stay internal layers (transient, not URL-addressable). Only top-level intents get URLs.
  • Phase 1 base is /cinema-os so the /cinema landing (CinemaLandingPage) is untouched. CinemaShell already portals to document.body (universal overlay root) β€” apps must NOT add their own portal.

Phase 2 (deferred)

DB history (cinema_sessions/cinema_events), opt-in keep-alive, browser-back↔OS-back, verses⇄topics swipe remain deferred.

Discipleship-tila is now an in-shell app (discipleship, done β€” the previously-deferred CinemaReaderScreen migration). CinemaReaderScreen is dual-mode: the embedded flag makes its outer wrapper skip its own CinemaPreferencesProvider AND its CinemaShell, and CinemaReaderScreenContent consumes useCinemaShell() instead of the render-prop ctx (the music-ref-sync just reads the same CinemaShellContext). DiscipleshipCinemaApp feeds it useDiscipleshipTasks() with no autoStart β†’ full useDiscipleshipOrchestration flow (selector β†’ reading + prayer + practice + memory quiz + Nyt KΓΆsete). ownsChrome: true (CinemaReader ControlBar + overlays replace OS pills; exit/ESC β†’ nav.back, completion β†’ nav.home); heavy engine/audio mount-bound (never keep-alive). Standalone /tanaan launch (TanaanModals) unchanged β€” it omits embedded. Same dual-mode + ownsChrome template as the prayer room.

Kooste (summary) is an in-shell app too (summary, done). SummaryCinemaApp resolves {summaryId?} (else active via useActiveSummary) β†’ fetchSummaryDetail (pages/summary/summaryDetailLoader) β†’ buildCinemaItems (pages/summary/SummaryCinemaMode, exported pure fns) β†’ dual-mode CinemaReaderScreen embedded mode="summaryItems". ownsChrome:true. Launched from the SearchCinemaApp "Muut" tab (Koosteet result) and a Command Center "Kooste" tile. SearchCinemaApp now has 4 tabs β€” Jakeet, Aiheet, Kysymykset (usePublishedQuestionSearch β†’ question intent), Muut (search_user_content RPC; only Koosteet links β†’ summary, rest read-only). Strongs is intentionally absent (no cinema target β†’ per the "no link for non-cinema" rule). The Jakeet tab is NOT an auto-carousel by default β€” it mirrors the base /search page. Data comes from useUnifiedSearch (NOT the old useVersesSearch) β†’ verses (match_rank/is_word_form) + topicalVerses. Hits are ordered literal-first then inflected (splitVersesByRank drops rank-5 fuzzy; compareByRankThenLocation within each group). Classification = isInflectedHit(v, query): single-word query β†’ inflected iff the verse text lacks the query as an exact token (Finnish FTS returns inflections as rank-1 WITHOUT is_word_form, so the rank/flag alone misses them); multi-word β†’ falls back to is_word_form || match_rank>=2. Inflected hits carry a TAIVUTUS badge. The "Rajaus taivutuksin" filter uses exact token match (NOT startsWith β€” the base form would otherwise match all its inflections and filter nothing). This orderedVerses drives the whole cinema verse search (list + preview + "Aja" carousel). Two filter dropdowns ("Rajaus kirjoittain" = books from results; "Rajaus taivutuksin" = extractWordForms, now EXPORTED from VerseFilterBar) are inline (NOT Radix Select β†’ stay visible in fullscreen). Pagination 16/page; below it the "Aiheeseen liittyvΓ€t jakeet" panel (topicalVerses deduped vs shown ids, VIA-topic tag) β€” both click β†’ CinemaVersePreviewPopup. Cards = wrapping dark boxes (flex flex-wrap); the snippet uses a cinema-local cinemaSnippet (match-LEADING, small lead) NOT the wide-page snippetAroundMatch (preContext 60) β€” in the narrow w-[300px] line-clamp-2 card the wide pre-context pushed the highlight past the 2 visible lines; leading "…" dropped at a clean word boundary. arrow keys rove the current page (column count from DOM offsetTop); click/ENTER opens the preview (found verse in chapter context, search term highlighted via the query prop + buildHighlightRegex, per-verse Film button β†’ TopicVerseCinema). "Aja jae-cinema" runs the full carousel startβ†’finish. Internal layers (list β†’ preview β†’ verseCinema, plus runAll) + useCinemaBackHandler β€” same model as ReadingCinemaApp, NO new app.

Prayer Room is now an in-shell app (prayer-room, done β€” was previously a separate-destination exclusion). PrayerRoomScreen is dual-mode: an embedded flag switches between mounting its own CinemaShell (standalone /rukoushuone) and consuming useCinemaShell() (under the host). It owns its chrome (ownsChrome: true on the registry entry β†’ CinemaOSChrome returns null for it; PrayerRoomHeader/BottomBar replace the OS pills, header X = nav.back, ESC closes an open invite/setup modal first via useCinemaBackHandler). Payload {roomId?/startCalendarId?/create?} is resolved by PrayerRoomApp (usePrayerRooms + resolveTodayPrayerRoomConfig/normalizeLoadedRoom); setup is an internal layer. Realtime/PTT/WebRTC are mount-bound (never keep-alive) β€” leaving the app leaves the room (channel removeChannel). The pattern (dual-mode

  • ownsChrome) is the template for migrating any other own-shell feature (e.g. the deferred Reader).

Cinema Mode Components

CinemaShell

Reusable base layer at src/components/cinema/CinemaShell.tsx (~438 lines):

// Provides:
// 1. Fullscreen modal shell (useCinemaFullscreen)
// 2. Animated background visuals (CinemaBackground + GSAP Ken Burns)
// 3. Background music (useCinemaAudio, music only)
// 4. User preferences (useCinemaPreferences)
// 5. Visual/music pickers + keyboard shortcuts (B/N/V/M)
// 6. Render props: CinemaShellContext (audioState, selectedTrack, preferences, toggleMusic, etc.)
// 7. dimControls prop to fade controls when overlays active

CinemaReaderScreen

Content layer at src/features/cinema/CinemaReaderScreen.tsx (~1033 lines):

// Wraps content in CinemaShell, adds:
// 1. Map BundleVerse β†’ CinemaVerse for cinema-reader package
// 2. Bible audio playback (separate from CinemaShell's music)
// 3. Handle verse navigation (index state)
// 4. Auto-advance (WPM-based or audio cue-based)
// 5. Audio sync (manual nav β†’ immediate audio-seek to that verse, no prompt)
// 6. Discipleship overlay rendering (delegates logic to useDiscipleshipOrchestration)
// 7. Compact mode for phones β€” passes compact + sheet callbacks to CinemaReader

Mobile UI (Compact Mode)

On phones (useBreakpoint().isPhone || isCozy, <768px) Cinema Mode collapses the desktop two-row control panel + top-left HUD pills into a single row + two icon-triggered bottom sheets, freeing ~90px for the verse text.

  • πŸ”Š icon β†’ CinemaMusicPopover (Raamatun versio + lukuÀÀni CinemaVersionVoiceSection, track, favorite, volume, favorites-only, change track)
  • βš™ icon β†’ CinemaSettingsSheet (background, completion mode, speed, voice volume + on/off)
  • Adaptive verse typography via CSS clamp() + vw (3 tiers; small-phone ≀380px gets 24-30px to fit Acts 1:14 on iPhone SE)
  • Sheets render inside native fullscreen via getCinemaPortalContainer() (Radix portal targeting fix)

Critical: @media (max-width: 479px) matches iPhone 14 Pro Max (430px). Use max-width: 380px for genuine "small phone" rules.

For full details (component contracts, control distribution table, font tier table, portal pattern, gotchas): See references/mobile-ui.md

useDiscipleshipOrchestration

Task queue hook at src/hooks/useDiscipleshipOrchestration.ts (~792 lines):

// Extracted from CinemaReaderScreen, manages:
// 1. Task queue state (queue, index, completedIds, overlays)
// 2. Auto-open logic (initialTask, autoStart, task selector)
// 3. Reading plan verse loading (loadReadingPlanReferences)
// 4. Kooste references loading (loadKoosteReferences)
// 5. Task completion persistence (persistTaskCompletion helper)
// 6. Quiz insertion after reading plan completion
// 7. Task routing, jumping, skipping
// 8. Mini-task choice routing
// 9. Prayer message handling

Animation Modes

Five animation modes in src/types/cinema.ts: slide, zoom, stack, loopH, loopV

Ken Burns Effect

CinemaBackground.tsx: Random transform over 25s (scale 1.0-1.15, translate Β±5%)

Audio System

ElevenLabs Integration

Voices in src/lib/elevenLabsVoices.ts: Venla (female, T5qAFgaL2uYxoUtojUzQ), Urho (male, 1WVCONUwYGulVaKg4oTr)

Audio Generation Flow

Client β†’ Edge Function β†’ check hash cache β†’ ElevenLabs API (with timestamps)
β†’ parse timestamps β†’ verse cues β†’ store MP3 β†’ save metadata + cues β†’ return

For detailed API reference, see references/elevenlabs-api.md

Audio Cue Format

See references/audio-cue-format.md for full specification.

Auto-Advance (Without Audio)

useAutoAdvance: WPM-based timing (default 150, adjustable 50-400). Min 1.5s per verse. Priority: audio cues > auto-advance timer.

Dual-Track Audio

useCinemaAudio: Bible + background music tracks with independent volume (0-1).

Database Schema

See existing tables: audio_assets, audio_cues, cinema_preferences, background_tracks, background_visuals in bible_schema.

Discipleship Cinema Mode

Immersive full-screen task flow launched from Tanaan page. Guides users through daily reading plans, prayers, practices, and memory quizzes sequentially.

For full details: See references/discipleship-cinema.md

Key Concepts

  • Launched via "Discipleship-tila" button on /tanaan β†’ CinemaReaderScreen(mode="verseList")
  • useTodayTasks normalizes practices + plans + prayers β†’ UnifiedTodayTask[]
  • useDiscipleshipTasks convenience hook composes all data sources
  • Task routing: reading_plan β†’ verse reader, prayer/practice β†’ DiscipleshipInlineTask
  • Quiz content types: verse_memory, read_memory, reading_plan_memory β†’ VerseMemoryQuiz
  • Between tasks: DiscipleshipTransitionOverlay with progress bar + free navigation
  • DiscipleshipVerseBar on pause: Add to Nyt Kooste, Note, Share, Pomodoro Break
  • Nyt Kooste: Auto-created daily verse collection, appended as final task
  • Reading Plan Memory Quiz: Auto-inserted after each reading plan day completion (discipleship mode only, NOT autoStart)
  • initialTask prop: Start specific task immediately, bypass selector
  • autoStart prop: Skip task selector, auto-queue all uncompleted tasks, auto-skip transitions
  • Break duration from app_config.discipleship_break_minutes (default 5 min)

Two Cinema Launch Modes from Tanaan

| Mode | Button | Props | Behavior | |------|--------|-------|----------| | Reading Plan Cinema | Blue Play button | discipleshipTasks={readingPlanCinemaTasks} autoStart | Auto-plays reading plans only. No task selector, no discipleship UI toggle, no memory quizzes, no transition overlays. | | Discipleship Cinema | Amber Clapperboard button | discipleshipTasks={allTasks} | Full discipleship flow: task selector β†’ reading + prayer + practice β†’ quizzes β†’ kooste. |

autoStart behavior:

  • Queues all uncompleted tasks, starts first immediately (line ~1071-1092)
  • Hides discipleship toggle/next button in CinemaReader (discipleshipMode={false}, no onDiscipleshipToggle)
  • Skips reading plan memory quizzes (!props.autoStart guard on quiz insertion)
  • Auto-skips transition overlays between tasks (useEffect at line ~1285-1290)
  • When all tasks done: calls onAllTasksCompleted and closes

Reading Plan Completion

handleComplete calls mark_reading_day_complete RPC when a reading plan finishes. Key details:

  • Completion-type plans: RPC advances current_day immediately (e.g. 5β†’6)
  • Dashboard fix: get_today_dashboard checks completed_at::date = CURRENT_DATE for completion-type plans (not current_day which already advanced)
  • Client-side fix: useTodayTasks checks completed_days.includes(current_day - 1) for completion-type plans as fallback
  • Query invalidation: Must invalidate ["today-dashboard"], ["reading-plan-streaks"], AND ["user-reading-plans"]

Reading Plan Memory Quiz

useReadingPlanQuiz generates a 3-verse quiz after reading plan completion (2 plan verses + 1 decoy). Selection priority: user-marked refs (marked_refs in quiz task metadata, from nytKoosteRefsRef) β†’ preferred books (NT, Psalms, Proverbs) β†’ any plan verse. Full algorithm: references/discipleship-cinema.md.

Tanaan Page & DiscipleshipLandingPage

/tanaan sections (TodayTaskList, TomorrowTasksSection, PermanentTasksSection, RecurringTasksSection, PracticeActivationCard) and the /opetuslapseus marketing landing page are documented in references/discipleship-cinema.md. Locations: Quick Reference β†’ Discipleship Layer / Pages.

Curated Plans (Grand Plans) & Progression Modes

Curated grand plans are a second CinemaShell consumer. The wrapper CuratedPlanCinema routes to one of two flow components based on the plan's progression_type, then renders inside CinemaShell hideControls.

For full details: See references/curated-plans.md

Progression Types

type GrandPlanProgressionType = 'continuous' | 'linear' | 'scrolling';

| Value | Flow Component | UX | |---|---|---| | continuous (default) | CuratedPlanFlow | One mini-task card at a time + countdown timer | | linear | CuratedPlanFlow | Same code path as continuous β€” semantic distinction only | | scrolling | ScrollingPlanFlow | All steps on one snap-scrollable page with auto-advance |

Flow router (CuratedPlanCinema.tsx line ~55):

const FlowComponent = progressionType === "scrolling" ? ScrollingPlanFlow : CuratedPlanFlow;

Both flows share the same props contract: { tasks, onAllTasksCompleted, onClose }.

CuratedPlanFlow (continuous / linear)

  • One mini-task card at a time via AnimatePresence mode="wait" crossfade
  • Scrim bg-black/40 + card bg-black/60 backdrop-blur-md max-w-md
  • Uses <MiniTaskView> (SVG circular countdown timer, pause via DiscipleshipPomodoroButton)
  • completedIds: Set<string> tracks done tasks; advance skips already-done
  • Backward jump via choice β†’ un-completes the target (allows replay)
  • X button = skip WITHOUT marking complete
  • Choice routing: {type:'next'} | {type:'jump_to_sort_order', sort_order} β€” sort_order matched against task.metadata.grand_plan_sort_order

ScrollingPlanFlow (scrolling)

  • All steps rendered on a single snap-scrollable page (snap-y snap-mandatory)
  • Numbered progress pills at top (click-to-jump), amber = active, emerald = past, faint = future
  • IntersectionObserver (threshold: [0, 0.25, 0.5, 0.75, 1], root = container) tracks active step; gates state update at ratio > 0.4 to prevent flicker
  • Per-step auto-advance: duration_seconds (default 30s) β†’ scrollToStep(i+1) unless user is scrolling
  • User-scroll detection: any scroll event flips userScrollingRef = true with 1500ms debounce β€” pauses auto-advance
  • Scrim bg-black/50 (stronger than CuratedPlanFlow)
  • Uses its own inline StepContent (NOT MiniTaskView) β€” no countdown ring, no completedIds
  • JATKA (non-last) = scroll, LOPETA (last) = finish + close

MiniTaskContentConfig (per-step data)

interface MiniTaskContentConfig {
  title: string;
  body: string;
  icon?: string;                // lucide icon via getPracticeIcon
  duration_seconds: number;     // timer (CuratedPlanFlow) OR auto-scroll delay (ScrollingPlanFlow)
  choices?: MiniTaskChoice[];   // if present β†’ hides JATKA in MiniTaskView (choice-to-advance)
  verse_ref?: string;           // resolved via fetchVerseTexts
  closing_text?: string;
  image_url?: string;
  show_break_button?: boolean;  // default true
}

Plan Completion

  • Last task / LOPETA β†’ flow calls onAllTasksCompleted() then onClose() synchronously
  • CuratedPlanCinema.handleAllTasksCompleted:
    1. Sets completedRef.current = true BEFORE the async RPC (close fires synchronously after)
    2. Calls supabase.rpc('complete_grand_plan', { p_grand_plan_id: planId })
    3. Invalidates ["grand-plans"]
  • handleClose β†’ onClose(), then if completedRef && completionRedirectUrl β†’ navigate(url) after 300ms

Critical Gotchas

  • continuous and linear are identical in code. Only "scrolling" branches β€” the other two both fall through to CuratedPlanFlow. Branch INSIDE the flow if you need them to differ, don't split the router.
  • ScrollingPlanFlow reimplements step UI. Inline StepContent is a simpler cousin of MiniTaskView (no timer, no pause). Behavioral changes to MiniTaskView do NOT propagate β€” fix both or refactor to share.
  • Snap-scroll mechanics are now extracted to useSnapScrollSteps, but ScrollingPlanFlow has NOT adopted it. src/hooks/useSnapScrollSteps.ts (active-index tracking, jump, user-scroll detection, per-step auto-advance) was extracted from ScrollingPlanFlow's proven behaviour and is used by InfoFlow. ScrollingPlanFlow still carries its own duplicate inline IntersectionObserver + timer. A snap-scroll fix in one does not reach the other β€” prefer migrating ScrollingPlanFlow onto the hook over editing both. Tuning constants (0.4 threshold, 1500ms user-scroll debounce) are deliberately matched; don't "clean up" either copy.
  • Completion order is load-bearing. completedRef.current = true must run BEFORE the await complete_grand_plan RPC β€” flow calls onAllTasksCompleted then onClose synchronously, and handleClose reads the ref immediately.
  • Two RPCs on mount. useCuratedPlanCinema fires get_grand_plan_items AND get_user_grand_plans (the latter only for progression_type). Consider folding progression_type into items if you refactor.
  • Default progression falls back to continuous. When the user isn't a member yet, get_user_grand_plans has no match β†’ silent continuous default. Worth checking first when a plan "won't scroll".
  • ScrollingPlanFlow doesn't invoke complete_grand_plan. That's CuratedPlanCinema's job. A new flow must call onAllTasksCompleted() at the end, or the plan never marks complete.
  • Intersection threshold 0.4 and user-scroll debounce 1500ms are tuned. Lowering the threshold flickers active-index; shortening the debounce fires auto-advance mid-flick. Don't "clean up" these constants.

Info Cinema (Q&A / Topic Info-palaset)

Third CinemaShell consumer. Renders a question's/topic's published Info-palaset as a one-page vertical snap-scroll β€” one card at a time, top β†’ bottom β€” the same UX as the curated ScrollingPlanFlow but for read-only answer content. This is the "yksi sivu jota mennÀÀn kortti kerrallaan alaspΓ€in" flow.

For full details: See references/info-cinema.md

Key Concepts

  • QuestionAnswersCinema (the Film CTA, 3 variants) β†’ InfoCinema (wrapper: CinemaShell hideControls + music/settings sheets, mirrors CuratedPlanCinema) β†’ InfoFlow (the snap-scroll) β†’ InfoView variant="scroll" (one block card).
  • Mechanics come from the shared useSnapScrollSteps hook β€” InfoFlow does NOT reimplement the IntersectionObserver. Per-card auto-advance from each block's duration_seconds (0 = none); user-scroll pauses it; last card never auto-advances.
  • finalSlide render-prop: optional extra snap-step after the blocks (index blocks.length), e.g. CinemaQuestionWrapUp (feedback bar + related questions). Gets { closeCinema, isActive }. Left-rail marker is a HelpCircle (not a number), auto-advance disabled. onFinalSlideEnter = its Enter-key default action (e.g. "NΓ€ytΓ€ seuraava kysymys"); Enter is reserved for this β€” never add Enter-to-advance on content cards.
  • contentKey remount pattern: InfoCinema passes contentKey (the questionId) to <InfoFlow key={contentKey}> β†’ CinemaShell stays mounted (bg/music continue) while InfoFlow remounts (scroll resets) β†’ seamless "next question" transition without leaving cinema. Same family as reading-plan key={cinema-day-N}.
  • InfoView is dual-variant: variant="scroll" (cinema) vs the page InfoTextCard. Same block, two renderers β€” edit the one the task asks for (see questions_answers manifest domain).
  • Renders nothing with 0 published+non-hidden blocks (CTA hidden, InfoCinema returns null). A "Cinema button missing" report usually means the publish gate, not cinema code.

Prayer Room (Rukoushuone)

Realtime multiplayer full-screen prayer experience built on CinemaShell (not CinemaReader). Canonical example of a CinemaShell-only consumer: reuses fullscreen + Ken Burns + background music + preferences, adds its own content, host-driven realtime sync, push-to-talk, and WebRTC audio mesh on top.

For full details: See references/prayer-room.md

Key Concepts

  • Route: /rukoushuone β†’ PrayerRoomPage β†’ PrayerRoomScreen(initialConfig?, onClose). Passing initialConfig opens straight into "room" view (skips setup) β€” this is how every "launch" entry point works.
  • Entry helpers: useTodayPrayerRoom opens stored room β†’ fallback most recent β†’ fresh "PΓ€ivΓ€n rukoushuone"
  • Prayer-calendar Cinema launch (CalendarDetailView, /rukouskalenterit?cal=<id>): a Film "Cinema" button navigates navigate("/rukoushuone", { state: { config } }) with the full today-room sources on (useMyCalendar/useSubscribedCalendars: true) plus calendarId (guarantees this calendar's prayers are present even if unsubscribed + enables per-day nav) and the transient startCalendarId field. PrayerRoomScreen runs a one-shot effect that jumps currentPrayerIndex to the first prayer whose calendarId === startCalendarId once the aggregated list has loaded it β€” so the room opens on that calendar but the rest stays arrow-navigable. startCalendarId is UI-only β€” NOT persisted (the prayer_rooms mutations whitelist columns), so saved/reopened rooms never re-trigger the jump.
  • Two views: "setup" (form) and "room" (CinemaShell hideControls + render props)
  • isHost = !config.id || !user ? true : config.hostUserId === user.id β€” solo sessions and anonymous users are always host
  • Content sources (merged + deduped by baseId = id.replace(/^(cal-|mine-|sub-)/, "")):
    • Ad-hoc config.prayers (uuid ids)
    • useMyCalendar β†’ user's own + followed active prayers (mine-<id>)
    • useSubscribedCalendars minus mutedCalendarIds (sub-<id>)
    • config.calendarId extra browsing calendar (cal-<id>)
  • Legacy rooms normalize useMyCalendar/useSubscribedCalendars to true; new rooms default toggles to false in setup β€” asymmetric by design
  • Host keyboard: ArrowLeft/Right (prayer nav), ArrowUp/Down (calendar day, only if config.calendarId)
  • Left weekday rail (PrayerRoomCalendarRail + useCalendarRailModel): for prayer-calendar cards (currentPrayer.source === "calendar") a vertical left rail shows the calendar's own scheduled weekdays (Mon–Sun, via getScheduledWeekdays), with the currently-browsed day as the single active marker β€” green, not amber, and no past/future colouring (unlike InfoFlow/ScrollingPlanFlow). Driven by calendarDayOffsets[currentPrayer.calendarId] (works for subscribed calendars too, not just config.calendarId). Moves with ArrowUp/Down + header chevrons; host can click a marker to jump (offset delta β†’ shiftCurrentCalendarDay + setCurrentPrayerIndex(0)). useCalendarRailModel is a pure useMemo placed after the realtime hooks (no hook-order risk).
  • Verses: OSIS is canonical removal key; adds and removes auto-persist when config.id exists

Realtime Multiplayer

Channel: prayer-room:<roomId> via Supabase Realtime.

  • Presence (keyed by user.id): displayName, avatarUrl fetched from profiles before tracking
  • Broadcast "sync": Host sends { currentPrayerIndex, currentVerseIndex, calendarDayOffset } debounced 100ms; participants apply in useEffect gated on !isHost && syncState
  • Push-to-talk (PTT): Single speaker at a time; FIFO handQueue; host grants via avatar click or autoGrantNext on release/leave; events: raise_hand, grant_talk, release_talk
  • WebRTC audio mesh: STUN-only (stun:stun.l.google.com:19302), 2–5 users, one RTCPeerConnection + <audio> per peer, signaling over the same channel; micOpen = activeSpeakerId === userId && pttPressed toggles track enable
  • Channel ownership: usePrayerRoomSync owns subscribe/unsubscribe. usePushToTalk and useWebRTCAudio attach listeners but do NOT unsubscribe.

Invitations

Table public.prayer_room_invitations with invitee_id OR invitee_email, status pending|accepted|declined. Invite flow auto-persists the room first via createRoom.mutateAsync when opening the invite dialog from an unsaved config.

Critical Gotchas

  • Modals inline, not portaled. Setup, invite, verse-full popup, and remove-verse confirmation render as absolute inset-0 z-[10003] children of the CinemaShell subtree. Do NOT use shadcn AlertDialog or Dialog β€” both auto-portal via Radix to document.body and become invisible inside the fullscreen element. asDialog / inline props on child dialogs exist for this. See references/learnings.md β†’ "Radix Dialog/AlertDialog Auto-Portals" for the full pattern.
  • Z-index stack: CinemaShell base < Header/BottomBar z-[10002] < inline modals z-[10003]. Don't invent new values.
  • Title pill is rendered by PrayerRoomHeader, not CinemaShell. Pass NO title prop to <CinemaShell> from PrayerRoomScreen β€” the header renders the pill on its own row 1 alongside the prayer-count nav. Adding the title back to CinemaShell yields a stacked duplicate.
  • calendarName already includes "Rukouskalenteri" prefix. It comes from the DB name column (e.g. "Rukouskalenteri Suomi"). Don't prepend prayerRoom.header.calendarPrefix again β€” that produced the duplicate "Rukouskalenteri Rukouskalenteri Suomi" bug. The Calendar icon next to the name carries the semantics.
  • Prayer typography matches .cinema-verse-text. PrayerRoomContent's prayer body uses clamp(36px, 5vw, 56px) + min(1100px, 88vw) lane β€” exactly the same as cinema-reader's verse text (packages/cinema-reader/src/styles/cinema.css). The middle container must NOT have max-w-2xl mx-auto or any narrower cap that would clip this lane.
  • src/hooks/usePrayerRoom.ts is unused. PrayerRoomScreen manages state inline; do not "refactor to use the hook" without migrating sync/PTT/WebRTC plumbing too.
  • prayer_rooms / prayer_room_invitations not in types.ts β€” hooks still use (supabase as any). When types regenerate, drop the casts (see /supabase-typing-architect).
  • STUN-only mesh fails on symmetric NAT / strict firewalls. Add TURN + SFU if scaling beyond 5 users.

Common Tasks

Add New Voice

  1. Get voice ID from ElevenLabs β†’ add to elevenLabsVoices.ts β†’ add to admin UI β†’ use elevenlabs:{voiceId} format

Debug Audio Sync

Add interval logging in useCinemaAudio to trace findCurrentCue() output.

References

  • references/audio-cue-format.md - Detailed cue timing specification
  • references/elevenlabs-api.md - ElevenLabs API reference
  • references/discipleship-cinema.md - Full discipleship cinema mode documentation (components, hooks, flows, gotchas)
  • references/reading-plan-transition.md - State flow for "Next Day" in reading plan cinema mode
  • references/info-cinema.md - Info Cinema (Q&A / topic info-palaset): one-page snap-scroll card flow, shared useSnapScrollSteps, finalSlide wrap-up, contentKey remount, dual-variant InfoView
  • references/prayer-room.md - Prayer Room (Rukoushuone): CinemaShell consumer with realtime sync, PTT, WebRTC audio mesh, invitations
  • references/curated-plans.md - Curated grand plans: progression modes (continuous / linear / scrolling), flow routing, completion RPC, MiniTask data model
  • references/mobile-ui.md - Cinema Mode mobile UI: compact controls (πŸ”Š + βš™ sheets), adaptive verse typography (clamp+vw, ≀380px tier), native fullscreen portal pattern, gotchas
  • references/learnings.md - Bug patterns and fixes
  • Docs/cinema/CINEMA-OS.md - Cinema OS / Command Center: persistent shell + Intent/Registry/Navigation, two-level back, deep links, history, KeepAlive/Frame State, Search Cinema, phasing (NOT under references/ β€” it's a shared system doc)

Cross-cutting learnings: See .claude/LEARNINGS.md β†’ "CSS/Layout" section for framer-motion patterns and animation gotchas.