A SvelteKit-based presentation system replicating PowerPoint's Custom Show "drill and return" functionality. Navigate through multi-slide presentations with fragment-by-fragment reveals, drill into scripture references, and automatically return to your exact position.
npm install
npm run devsrc/
├── lib/
│ ├── components/
│ │ ├── Fragment.svelte # Controls fragment visibility & drills
│ │ └── Slide.svelte # Wrapper that auto-registers maxStep
│ ├── stores/
│ │ └── navigation.ts # Core navigation state machine
│ └── types.ts
├── routes/
│ ├── +layout.svelte # Global keyboard handling
│ ├── +page.svelte # Main menu
│ └── demo/ # "Demo" presentation
│ ├── +page.svelte # Presentation controller
│ ├── slides/ # Slide components
│ │ ├── Slide1.svelte
│ │ ├── Slide2.svelte
│ │ └── Slide3.svelte
│ ├── ecclesiastes.3.19/ # Drill route
│ │ └── +page.svelte
│ └── 1thessalonians.5.23/ # Drill route
│ └── +page.svelte
└── tests/
├── navigation.test.ts # 27 navigation store tests
└── fragment.test.ts # 9 component tests
Create a presentation page that collects maxStep from each slide:
<!-- routes/lesson/+page.svelte -->
<script lang="ts">
import { navigation, currentSlide } from '$lib/stores/navigation';
import { onMount } from 'svelte';
import Slide1 from './slides/Slide1.svelte';
import Slide2 from './slides/Slide2.svelte';
// Slides report their maxStep via callback
let slideMaxSteps = $state<number[]>([0, 0]);
// Update navigation when all slides have reported
function updateNavigation() {
if (slideMaxSteps.every(s => s > 0)) {
navigation.init('lesson', slideMaxSteps);
}
}
function handleSlide1MaxStep(maxStep: number) {
slideMaxSteps[0] = maxStep;
updateNavigation();
}
function handleSlide2MaxStep(maxStep: number) {
slideMaxSteps[1] = maxStep;
updateNavigation();
}
// Validate all slides registered properly
onMount(() => {
setTimeout(() => {
if (!slideMaxSteps.every(s => s > 0)) {
const missing = slideMaxSteps
.map((s, i) => s === 0 ? i + 1 : null)
.filter(Boolean);
throw new Error(`Slides ${missing.join(', ')} did not report maxStep. Wrap content in <Slide>.`);
}
}, 100);
});
</script>
<!-- Render all slides, show only active one -->
<div class="slide-wrapper" class:active={$currentSlide === 0}>
<Slide1 onMaxStep={handleSlide1MaxStep} />
</div>
<div class="slide-wrapper" class:active={$currentSlide === 1}>
<Slide2 onMaxStep={handleSlide2MaxStep} />
</div>
<style>
.slide-wrapper { visibility: hidden; position: absolute; }
.slide-wrapper.active { visibility: visible; position: relative; }
</style>Wrap slide content in the Slide component. Each Fragment automatically registers its step:
<!-- routes/lesson/slides/Slide1.svelte -->
<script lang="ts">
import Fragment from '$lib/components/Fragment.svelte';
import Slide from '$lib/components/Slide.svelte';
interface Props {
onMaxStep?: (maxStep: number) => void;
}
let { onMaxStep }: Props = $props();
</script>
<Slide {onMaxStep}>
<Fragment step={1}>
<h1>First thing revealed</h1>
</Fragment>
<Fragment step={2}>
<p>Second thing</p>
</Fragment>
<!-- Appears with step 2 (same integer = same click) -->
<Fragment step={2.1}>
<p>Also appears at step 2 (with 500ms delay)</p>
</Fragment>
<!-- Drillable - clicking navigates to the drill route -->
<Fragment step={4} drillTo="lesson/scripture-ref">
<span>Click to drill into scripture</span>
</Fragment>
</Slide>How it works: The Slide component creates a context. Each Fragment registers its step value with that context. The Slide tracks the highest step seen and reports it via onMaxStep callback.
Drills are single-slide presentations. Wrap content in <Slide> without an onMaxStep callback — the Slide will auto-register maxFragment:
<!-- routes/lesson/scripture-ref/+page.svelte -->
<script lang="ts">
import Slide from '$lib/components/Slide.svelte';
import Fragment from '$lib/components/Fragment.svelte';
</script>
<Slide>
<Fragment step={1}>Verse 1</Fragment>
<Fragment step={2}>Verse 2</Fragment>
<Fragment step={3}>Verse 3</Fragment>
<Fragment step={4}>Key insight - press → to return</Fragment>
</Slide>For nested drills (drill within drill), the last Fragment can include a drillTo:
<!-- Drill that chains to another drill -->
<script lang="ts">
import Slide from '$lib/components/Slide.svelte';
import Fragment from '$lib/components/Fragment.svelte';
</script>
<Slide>
<Fragment step={1}>First point</Fragment>
<Fragment step={2}>Second point</Fragment>
<!-- Last fragment with drillTo - auto-advances when → is pressed at step 3 -->
<Fragment step={3} drillTo="lesson/related-scripture">
<span>Related scripture →</span>
</Fragment>
</Slide>The drillTo prop on any Fragment automatically enables:
- Click navigation: Clicking a visible drillTo fragment navigates to that route
- Auto-advance: When at the last fragment and it has a
drillTo, pressing → auto-navigates - Return to origin: When returning from nested drills, you go directly back to the original presentation, preserving the exact fragment position
The <Slide> component is the foundation for both presentations and drills:
| Usage | Behavior |
|---|---|
<Slide onMaxStep={callback}> |
Multi-slide: reports maxStep to parent for navigation.init() |
<Slide> (no callback) |
Single-slide drill: auto-registers with navigation. Can chain to other drills via drillTo on the last Fragment, returning to the original presentation when complete. |
Note: You don't need to declare drillTo targets separately - just use drillTo on your Fragment components. They auto-register with the navigation store.
autoDrillAll Setting: MBS has an autoDrillAll toggle:
autoDrillAll=true(default): Arrow keys execute drills automatically in animation sequenceautoDrillAll=false: Arrow keys skip drills; user must click to drill- This applies to ALL drillTo fragments, including ones at the last fragment
The autoDrillAll setting controls whether arrow keys auto-execute drills:
| Setting | Arrow Behavior | Click Behavior |
|---|---|---|
autoDrillAll=true (default) |
Executes drills automatically | Always drills |
autoDrillAll=false |
Skips all drills | Always drills |
Multi-level drills: When drills chain (drill-01 → drill-02 → drill-03), completing the deepest drill returns directly to the origin presentation, not back through each level.
returnHere prop: Use returnHere on a Fragment to return to the parent drill instead of origin:
<Fragment step={2} drillTo="demo/nested" returnHere>
Returns to THIS drill after nested completes
</Fragment>The Slide component uses Svelte context to collect step values from child Fragment components:
Slidecreates a context withregisterStep()function- Each
FragmentcallsregisterStep(step)when it mounts Slidetracks the max step seen- With callback: Reports to parent → parent calls
navigation.init() - Without callback: Directly calls
navigation.setMaxFragment()
| Prop | Type | Description |
|---|---|---|
step |
number |
Step number when content appears. Integer = click number, decimal = delay (e.g., 2.1 = step 2 with 500ms delay). Omit for static content. |
drillTo |
string |
Route to drill into on click (e.g., "demo/ecclesiastes.3.19") |
returnHere |
boolean |
Return to this drill (not origin) after nested drill completes |
layout |
BoxLayout |
Absolute positioning: { x, y, width, height, rotation? } |
font |
BoxFont |
Typography: { font_name?, font_size?, bold?, italic?, color?, alignment? } |
fill |
string |
Background color (e.g., "var(--bg-ghost)") |
line |
BoxLine |
Border: { color?, width? } |
zIndex |
number |
Stacking order |
animate |
AnimationType |
Entrance animation: 'fade', 'fly-up', 'fly-down', 'fly-left', 'fly-right', 'scale' |
| Key | Action |
|---|---|
→ Space PageDown |
Next fragment/slide |
← PageUp |
Previous fragment/slide |
Escape |
Return to menu |
// Initialize multi-slide presentation (called by parent after collecting maxSteps)
navigation.init('demo', [9, 15, 12]);
// Navigation
navigation.next(); // Advance fragment, slide, auto-drill, or auto-return
navigation.prev(); // Go back
navigation.goToSlide(1); // Jump to slide (preserves fragment position)
navigation.goToFragment(5); // Jump to fragment in current slide
// Drill operations (usually triggered automatically via Fragment drillTo)
navigation.drillInto('demo/ecclesiastes.3.19'); // Push state, navigate
navigation.returnFromDrill(); // Pop all states, return to origin
// State management
navigation.clearPresentation('demo'); // Clear localStorage for presentation
navigation.reset(); // Reset everythingUnit tests and E2E tests cover all core navigation functionality:
npm run test:unit # 114 unit tests
npx playwright test # 11 E2E tests- 114 unit tests covering navigation store and Fragment component
- 11 E2E tests covering drill navigation, multi-level drills, and autoDrillAll behavior
- Tests are content-agnostic - they test mechanics, not specific presentations
Navigation state is saved to localStorage under key mbs-nav-state. This preserves:
- Current slide and fragment position
- Per-slide fragment positions (so jumping between slides remembers where you were)
- Drill stack (for nested drills)
The menu's Reset button clears this state.
-
Wrap slides in
<Slide>- Without the Slide wrapper, Fragments won't register their steps and maxStep won't be reported. -
Render all slides - Use
visibility: hiddennotdisplay: nonefor inactive slides. They need to mount to register their steps. -
Drills use
<Slide>too - Wrap drill content in<Slide>without anonMaxStepcallback. The Slide will auto-callsetMaxFragment(). -
Fragment steps are 1-indexed - Start with
step={1}, notstep={0}. -
drillToroutes are relative - Use"life/ecclesiastes.3.19"not"/life/ecclesiastes.3.19". -
Auto-return at end of drill - When
next()is called on the last fragment and there's a stack, it auto-returns to origin (not the immediate parent). -
autoDrillAll affects ALL drills - When disabled, arrow keys skip drills even at the last fragment. Users must click to drill.
-
Multi-level drills return to origin - A 3-level drill chain (A → B → C) returns directly to A when C completes, not back through B.
-
Auto-return at end of drill - When
next()is called on the last step and there's a stack, it auto-returns to origin.
Master Bible Study is built on foundational theological truths that shape all its conclusions. Understanding these foundations is essential for accurately using or adapting MBS content.
See THEOLOGY.md for details on the foundational truths and how they inform MBS interpretations and conclusions.
This project is licensed under the Creative Commons Attribution 4.0 International License (CC-BY 4.0).
You are free to use, modify, and distribute this work with proper attribution to the original source.
How to attribute: Include a link to this repository and reference the CC-BY 4.0 license. This allows anyone to compare your version with the original if modifications are made.
See the LICENSE file for complete details.