learningfoundry 0.40.0__tar.gz → 0.45.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/CHANGELOG.md +54 -0
  2. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/PKG-INFO +38 -2
  3. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/README.md +37 -1
  4. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/pyproject.toml +1 -1
  5. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/__init__.py +1 -1
  6. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/config.py +15 -0
  7. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/resolver.py +13 -0
  8. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/schema_v1.py +11 -0
  9. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +12 -3
  10. {learningfoundry-0.40.0 → learningfoundry-0.45.0/src/learningfoundry}/sveltekit_template/src/lib/components/LessonList.svelte +28 -9
  11. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +81 -0
  12. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +68 -0
  13. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +103 -0
  14. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/components/Navigation.svelte +10 -5
  15. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +45 -20
  16. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +118 -0
  17. {learningfoundry-0.40.0 → learningfoundry-0.45.0/src/learningfoundry}/sveltekit_template/src/lib/components/QuizBlock.svelte +8 -1
  18. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +51 -0
  19. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +78 -0
  20. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +130 -0
  21. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +96 -0
  22. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +48 -0
  23. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/module-list.helpers.ts +105 -0
  24. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/module-list.test.ts +65 -0
  25. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +39 -0
  26. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +98 -0
  27. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +28 -0
  28. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +12 -1
  29. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +238 -0
  30. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/utils/locking.ts +147 -0
  31. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +64 -0
  32. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +12 -18
  33. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +2 -22
  34. {learningfoundry-0.40.0 → learningfoundry-0.45.0/src/learningfoundry}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +3 -3
  35. learningfoundry-0.45.0/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +93 -0
  36. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/ContentBlock.svelte +12 -3
  37. {learningfoundry-0.40.0/src/learningfoundry → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/LessonList.svelte +28 -9
  38. learningfoundry-0.45.0/sveltekit_template/src/lib/components/LessonView.svelte +81 -0
  39. learningfoundry-0.45.0/sveltekit_template/src/lib/components/ModuleList.svelte +103 -0
  40. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/Navigation.svelte +10 -5
  41. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/ProgressDashboard.svelte +45 -20
  42. {learningfoundry-0.40.0/src/learningfoundry → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/QuizBlock.svelte +8 -1
  43. learningfoundry-0.45.0/sveltekit_template/src/lib/components/TextBlock.svelte +51 -0
  44. learningfoundry-0.45.0/sveltekit_template/src/lib/components/VideoBlock.svelte +130 -0
  45. learningfoundry-0.45.0/sveltekit_template/src/lib/components/module-list.helpers.ts +105 -0
  46. learningfoundry-0.45.0/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +39 -0
  47. learningfoundry-0.45.0/sveltekit_template/src/lib/stores/progress.ts +28 -0
  48. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/types/index.ts +12 -1
  49. learningfoundry-0.45.0/sveltekit_template/src/lib/utils/locking.ts +147 -0
  50. learningfoundry-0.45.0/sveltekit_template/src/lib/utils/viewport-completion.ts +64 -0
  51. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/routes/+layout.svelte +12 -19
  52. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/routes/+page.svelte +2 -22
  53. {learningfoundry-0.40.0/src/learningfoundry → learningfoundry-0.45.0}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +4 -18
  54. learningfoundry-0.40.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +0 -44
  55. learningfoundry-0.40.0/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +0 -67
  56. learningfoundry-0.40.0/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +0 -16
  57. learningfoundry-0.40.0/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -45
  58. learningfoundry-0.40.0/sveltekit_template/src/lib/components/LessonView.svelte +0 -44
  59. learningfoundry-0.40.0/sveltekit_template/src/lib/components/ModuleList.svelte +0 -67
  60. learningfoundry-0.40.0/sveltekit_template/src/lib/components/TextBlock.svelte +0 -16
  61. learningfoundry-0.40.0/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -45
  62. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/.gitignore +0 -0
  63. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/LICENSE +0 -0
  64. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/docs/project-guide/README.md +0 -0
  65. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/__main__.py +0 -0
  66. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/asset_resolver.py +0 -0
  67. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/cli.py +0 -0
  68. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/exceptions.py +0 -0
  69. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/generator.py +0 -0
  70. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/integrations/__init__.py +0 -0
  71. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
  72. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
  73. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/integrations/protocols.py +0 -0
  74. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/integrations/quizazz.py +0 -0
  75. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/logging_config.py +0 -0
  76. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/parser.py +0 -0
  77. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/pipeline.py +0 -0
  78. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/py.typed +0 -0
  79. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/package.json +0 -0
  80. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
  81. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
  82. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
  83. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  84. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  85. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  86. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  87. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
  88. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +0 -0
  89. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/db/progress.ts +0 -0
  90. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
  91. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
  92. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
  93. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  94. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
  95. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
  96. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
  97. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
  98. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
  99. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
  100. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
  101. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/app.css +0 -0
  102. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/app.html +0 -0
  103. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  104. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  105. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  106. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  107. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/db/database.ts +0 -0
  108. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/db/index.ts +0 -0
  109. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/db/progress.ts +0 -0
  110. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
  111. {learningfoundry-0.40.0 → learningfoundry-0.45.0}/sveltekit_template/src/lib/utils/markdown.ts +0 -0
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.45.0] - 2026-04-30
11
+
12
+ ### Added
13
+
14
+ - **Locking and unlocking UI.** Frontend implementation of the locking model introduced in v0.44.0:
15
+ - New `$lib/utils/locking.ts` with pure functions `isModuleLocked`, `isLessonLocked`, `getOptionalLessons`, `isModuleComplete`, plus convenience set helpers `lockedModuleIds` / `lockedLessonIds`.
16
+ - `ModuleList.svelte` renders a Lucide `Lock` icon, suppresses expansion, and skips the active-module highlight for locked modules.
17
+ - `LessonList.svelte` shows a `◇` indicator for optional lessons (sibling lessons within a module whose `unlock_module_on_complete` lesson has been completed) and renders locked lessons as muted, non-clickable rows.
18
+ - `ProgressDashboard.svelte` uses `isModuleComplete` for per-module status (treats optional lessons as not blocking module completion) while still counting all completed lessons toward the curriculum-level progress bar.
19
+ - Type extensions: `LessonStatus` gains `'optional'`; `Lesson.unlock_module_on_complete?`, `Module.locked?`, `Curriculum.locking?`, and a new `LockingConfig` interface.
20
+ - The unlock cascade is fully reactive: completing an `unlock_module_on_complete` lesson refreshes `progressStore` once via `invalidateProgress`, and all locked/optional state re-derives automatically — no extra DB writes or store updates required.
21
+
22
+ ## [0.44.0] - 2026-04-30
23
+
24
+ ### Added
25
+
26
+ - **Locking configuration schema.** Python-side schema, resolver, and config support for sequential content access control:
27
+ - `LockingConfig` Pydantic model (`sequential`, `lesson_sequential`) on `CurriculumDef`.
28
+ - `Module.locked: bool | None` per-module override.
29
+ - `Lesson.unlock_module_on_complete: bool` gateway-lesson flag.
30
+ - `QuizBlock.pass_threshold: float` (0.0–1.0) for quiz completion scoring.
31
+ - Global config (`~/.config/learningfoundry/config.yml`) gains `locking` block with the same fields.
32
+ - Config hierarchy: global defaults → curriculum YAML `locking` → per-module `locked` override.
33
+ - All fields propagate through the resolver into `curriculum.json` for frontend consumption (Story I.j).
34
+
35
+ ## [0.43.0] - 2026-04-30
36
+
37
+ ### Added
38
+
39
+ - **Curriculum-level progress bar on the dashboard.** `ProgressDashboard.svelte` now renders a summary bar above the module cards showing `"{totalComplete} of {totalLessons} lessons completed"`. Computed reactively from `progressStore` — updates live when lessons complete during the session. Hidden when the curriculum has zero lessons. The dashboard `+page.svelte` no longer does its own one-shot progress fetch; it reads from the shared `progressStore` populated by the layout.
40
+
41
+ ## [0.42.0] - 2026-04-30
42
+
43
+ ### Added
44
+
45
+ - **Block completion events drive lesson auto-complete.** Each content block fires an independent completion event when sufficiently engaged with: `TextBlock` after 1 s in the viewport (IntersectionObserver + debounce timer), `VideoBlock` on YouTube IFrame Player API `ENDED` state (falls back to 3 s viewport if API fails to load within 5 s), `QuizBlock` when score ≥ `passThreshold` (default 0.0). `LessonView` tracks which blocks have completed; when all have fired, the lesson is marked complete in SQLite and the sidebar updates immediately.
46
+ - **Reactive progress store** (`$lib/stores/progress.ts`). `progressStore` is a writable Svelte store holding `Record<string, ModuleProgress>`. `invalidateProgress(curriculum)` re-fetches all module progress from SQLite and writes to the store. `+layout.svelte` subscribes to this store instead of a one-shot `$effect` fetch; lesson completions call `invalidateProgress` so the sidebar reflects changes without a page reload.
47
+ - **Revisit behaviour.** On mount, `LessonView` reads the lesson's current DB status; if already `complete`, all blocks are pre-filled as done so the Next/Finish button is immediately active.
48
+ - **Zero-block edge case.** A lesson with no content blocks is treated as immediately complete on mount.
49
+ - **`QuizManifest.passThreshold`** added to TypeScript types for future quiz scoring threshold support.
50
+
51
+ ### Changed
52
+
53
+ - **Next/Finish no longer trigger completion marking.** Navigation is decoupled from completion: `Navigation.svelte` handles its own routing (Next → `navigateTo`, Finish → `goto('/')`) and accepts a `disabled` prop. The `onComplete` callback prop has been removed. `LessonView` no longer has `handleNavComplete` or `oncomplete`.
54
+ - **`+page.svelte`** migrated from deprecated `$app/stores` to `$app/state` for route params.
55
+
56
+ ## [0.41.0] - 2026-04-30
57
+
58
+ ### Fixed
59
+
60
+ - **Finish button on last lesson now navigates to the dashboard.** `+page.svelte` was not passing an `oncomplete` handler to `<LessonView>`; clicking Finish was a no-op. It now calls `goto('/')` to redirect to the progress dashboard.
61
+ - **Sidebar module expand/contract no longer reverts immediately.** The `$effect` in `ModuleList.svelte` read `expandedModuleId` (the value it writes), creating a self-dependency that overwrote manual toggles on every re-run. A separate `lastAutoExpandedModuleId` state variable breaks the cycle — the effect only fires when `currentPosition.moduleId` changes to a genuinely new value.
62
+ - **Active module in sidebar is now visually highlighted.** The module card containing the current lesson receives a left-border accent (`border-l-2 border-l-blue-500`) and a light background tint (`bg-blue-50`).
63
+
10
64
  ## [0.40.0] - 2026-04-29
11
65
 
12
66
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learningfoundry
3
- Version: 0.40.0
3
+ Version: 0.45.0
4
4
  Summary: A curriculum engine that turns a YAML curriculum definition into a deployable SvelteKit learning application.
5
5
  Project-URL: Homepage, https://github.com/pointmatic/learningfoundry
6
6
  Project-URL: Repository, https://github.com/pointmatic/learningfoundry
@@ -48,6 +48,7 @@ A curriculum engine that turns a YAML curriculum definition into a deployable Sv
48
48
  - [Video blocks](#video-blocks)
49
49
  - [Lesson titles and markdown headings](#lesson-titles-and-markdown-headings)
50
50
  - [Images and assets](#images-and-assets)
51
+ - [Content locking](#content-locking)
51
52
  - [Configuration File](#configuration-file)
52
53
  - [Development Setup](#development-setup)
53
54
 
@@ -389,9 +390,40 @@ For production deployment to a CDN, just run `cd dist && pnpm build` — the `st
389
390
 
390
391
  ---
391
392
 
393
+ ## Content locking
394
+
395
+ Control access to modules and lessons with a three-level configuration hierarchy (most local wins):
396
+
397
+ 1. **Per-module `locked`** — explicit `true`/`false` override; trumps everything.
398
+ 2. **Curriculum `locking.sequential`** — when true, module N+1 requires module N complete.
399
+ 3. **Global config `locking.sequential`** — project-wide default (see Configuration File below).
400
+
401
+ ```yaml
402
+ curriculum:
403
+ locking:
404
+ sequential: true # modules must be completed in order
405
+ lesson_sequential: false # lessons within a module are free-order
406
+
407
+ modules:
408
+ - id: mod-01
409
+ locked: false # always accessible regardless of sequential
410
+ lessons:
411
+ - id: lesson-01
412
+ unlock_module_on_complete: true # completing this unlocks siblings + next module
413
+ content_blocks:
414
+ - type: quiz
415
+ source: quizazz
416
+ ref: assessments/quiz.yml
417
+ pass_threshold: 0.7 # 70% required to count as passed
418
+ ```
419
+
420
+ `unlock_module_on_complete` is useful for "gateway" lessons — a single assessment that, once passed, opens the rest of the module and the next one.
421
+
422
+ ---
423
+
392
424
  ## Configuration File
393
425
 
394
- An optional config file can set defaults for logging. The CLI always takes precedence.
426
+ An optional config file can set defaults for logging and locking. The CLI always takes precedence.
395
427
 
396
428
  **Default location:** `~/.config/learningfoundry/config.yml`
397
429
 
@@ -399,6 +431,10 @@ An optional config file can set defaults for logging. The CLI always takes prece
399
431
  logging:
400
432
  level: INFO # DEBUG | INFO | WARNING | ERROR
401
433
  output: stdout # stdout | stderr
434
+
435
+ locking:
436
+ sequential: false # default for all curricula on this machine
437
+ lesson_sequential: false
402
438
  ```
403
439
 
404
440
  Pass a custom config location with `-c / --config`.
@@ -19,6 +19,7 @@ A curriculum engine that turns a YAML curriculum definition into a deployable Sv
19
19
  - [Video blocks](#video-blocks)
20
20
  - [Lesson titles and markdown headings](#lesson-titles-and-markdown-headings)
21
21
  - [Images and assets](#images-and-assets)
22
+ - [Content locking](#content-locking)
22
23
  - [Configuration File](#configuration-file)
23
24
  - [Development Setup](#development-setup)
24
25
 
@@ -360,9 +361,40 @@ For production deployment to a CDN, just run `cd dist && pnpm build` — the `st
360
361
 
361
362
  ---
362
363
 
364
+ ## Content locking
365
+
366
+ Control access to modules and lessons with a three-level configuration hierarchy (most local wins):
367
+
368
+ 1. **Per-module `locked`** — explicit `true`/`false` override; trumps everything.
369
+ 2. **Curriculum `locking.sequential`** — when true, module N+1 requires module N complete.
370
+ 3. **Global config `locking.sequential`** — project-wide default (see Configuration File below).
371
+
372
+ ```yaml
373
+ curriculum:
374
+ locking:
375
+ sequential: true # modules must be completed in order
376
+ lesson_sequential: false # lessons within a module are free-order
377
+
378
+ modules:
379
+ - id: mod-01
380
+ locked: false # always accessible regardless of sequential
381
+ lessons:
382
+ - id: lesson-01
383
+ unlock_module_on_complete: true # completing this unlocks siblings + next module
384
+ content_blocks:
385
+ - type: quiz
386
+ source: quizazz
387
+ ref: assessments/quiz.yml
388
+ pass_threshold: 0.7 # 70% required to count as passed
389
+ ```
390
+
391
+ `unlock_module_on_complete` is useful for "gateway" lessons — a single assessment that, once passed, opens the rest of the module and the next one.
392
+
393
+ ---
394
+
363
395
  ## Configuration File
364
396
 
365
- An optional config file can set defaults for logging. The CLI always takes precedence.
397
+ An optional config file can set defaults for logging and locking. The CLI always takes precedence.
366
398
 
367
399
  **Default location:** `~/.config/learningfoundry/config.yml`
368
400
 
@@ -370,6 +402,10 @@ An optional config file can set defaults for logging. The CLI always takes prece
370
402
  logging:
371
403
  level: INFO # DEBUG | INFO | WARNING | ERROR
372
404
  output: stdout # stdout | stderr
405
+
406
+ locking:
407
+ sequential: false # default for all curricula on this machine
408
+ lesson_sequential: false
373
409
  ```
374
410
 
375
411
  Pass a custom config location with `-c / --config`.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "learningfoundry"
7
- version = "0.40.0"
7
+ version = "0.45.0"
8
8
  description = "A curriculum engine that turns a YAML curriculum definition into a deployable SvelteKit learning application."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,4 +1,4 @@
1
1
  # Copyright 2026 Pointmatic
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
 
4
- __version__ = "0.40.0"
4
+ __version__ = "0.45.0"
@@ -16,6 +16,7 @@ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "learningfoundry" / "config.yml"
16
16
 
17
17
  _KNOWN_KEYS: dict[str, set[str]] = {
18
18
  "logging": {"level", "output"},
19
+ "locking": {"sequential", "lesson_sequential"},
19
20
  }
20
21
 
21
22
 
@@ -25,9 +26,16 @@ class LoggingConfig:
25
26
  output: str = "stdout"
26
27
 
27
28
 
29
+ @dataclass
30
+ class LockingConfig:
31
+ sequential: bool = False
32
+ lesson_sequential: bool = False
33
+
34
+
28
35
  @dataclass
29
36
  class AppConfig:
30
37
  logging: LoggingConfig = field(default_factory=LoggingConfig)
38
+ locking: LockingConfig = field(default_factory=LockingConfig)
31
39
 
32
40
 
33
41
  def load_config(
@@ -66,6 +74,13 @@ def load_config(
66
74
  config.logging.level = logging_raw["level"]
67
75
  if "output" in logging_raw:
68
76
  config.logging.output = logging_raw["output"]
77
+ locking_raw = raw.get("locking", {})
78
+ if "sequential" in locking_raw:
79
+ config.locking.sequential = bool(locking_raw["sequential"])
80
+ if "lesson_sequential" in locking_raw:
81
+ config.locking.lesson_sequential = bool(
82
+ locking_raw["lesson_sequential"]
83
+ )
69
84
 
70
85
  if cli_overrides:
71
86
  if "log_level" in cli_overrides and cli_overrides["log_level"] is not None:
@@ -40,6 +40,7 @@ class ResolvedContentBlock:
40
40
  class ResolvedLesson:
41
41
  id: str
42
42
  title: str
43
+ unlock_module_on_complete: bool = False
43
44
  content_blocks: list[ResolvedContentBlock] = field(default_factory=list)
44
45
 
45
46
 
@@ -48,6 +49,7 @@ class ResolvedModule:
48
49
  id: str
49
50
  title: str
50
51
  description: str
52
+ locked: bool | None
51
53
  pre_assessment: dict[str, Any] | None
52
54
  post_assessment: dict[str, Any] | None
53
55
  lessons: list[ResolvedLesson] = field(default_factory=list)
@@ -58,6 +60,7 @@ class ResolvedCurriculum:
58
60
  version: str
59
61
  title: str
60
62
  description: str
63
+ locking: dict[str, Any] = field(default_factory=dict)
61
64
  modules: list[ResolvedModule] = field(default_factory=list)
62
65
  # Image assets referenced from any text block's markdown, deduped by
63
66
  # content hash. Carried out-of-band — the generator copies these into
@@ -122,10 +125,17 @@ def resolve_curriculum(
122
125
  )
123
126
  )
124
127
 
128
+ locking = curriculum.curriculum.locking
129
+ locking_dict: dict[str, Any] = {
130
+ "sequential": locking.sequential,
131
+ "lesson_sequential": locking.lesson_sequential,
132
+ }
133
+
125
134
  return ResolvedCurriculum(
126
135
  version=curriculum.version,
127
136
  title=curriculum.curriculum.title,
128
137
  description=curriculum.curriculum.description,
138
+ locking=locking_dict,
129
139
  modules=resolved_modules,
130
140
  assets=list(assets_by_dest.values()),
131
141
  )
@@ -174,6 +184,7 @@ def _resolve_module(
174
184
  id=module.id,
175
185
  title=module.title,
176
186
  description=module.description,
187
+ locked=module.locked,
177
188
  pre_assessment=pre,
178
189
  post_assessment=post,
179
190
  lessons=resolved_lessons,
@@ -206,6 +217,7 @@ def _resolve_lesson(
206
217
  return ResolvedLesson(
207
218
  id=lesson.id,
208
219
  title=lesson.title,
220
+ unlock_module_on_complete=lesson.unlock_module_on_complete,
209
221
  content_blocks=resolved_blocks,
210
222
  )
211
223
 
@@ -228,6 +240,7 @@ def _resolve_block(
228
240
  manifest = quiz_provider.compile_assessment(
229
241
  Path(block.ref), base_dir
230
242
  )
243
+ manifest["pass_threshold"] = block.pass_threshold
231
244
  return ResolvedContentBlock(
232
245
  type="quiz", source=block.source, ref=block.ref, content=manifest
233
246
  )
@@ -63,6 +63,7 @@ class QuizBlock(BaseModel):
63
63
  type: Literal["quiz"]
64
64
  source: str
65
65
  ref: str
66
+ pass_threshold: float = Field(0.0, ge=0.0, le=1.0)
66
67
 
67
68
 
68
69
  class ExerciseBlock(BaseModel):
@@ -86,6 +87,7 @@ ContentBlock = Annotated[
86
87
  class Lesson(BaseModel):
87
88
  id: str
88
89
  title: str
90
+ unlock_module_on_complete: bool = False
89
91
  content_blocks: list[ContentBlock]
90
92
 
91
93
  @field_validator("id")
@@ -98,6 +100,7 @@ class Module(BaseModel):
98
100
  id: str
99
101
  title: str
100
102
  description: str = ""
103
+ locked: bool | None = None
101
104
  pre_assessment: AssessmentRef | None = None
102
105
  post_assessment: AssessmentRef | None = None
103
106
  lessons: list[Lesson]
@@ -114,9 +117,17 @@ class Module(BaseModel):
114
117
  return self
115
118
 
116
119
 
120
+ class LockingConfig(BaseModel):
121
+ """Curriculum-level content locking configuration."""
122
+
123
+ sequential: bool = False
124
+ lesson_sequential: bool = False
125
+
126
+
117
127
  class CurriculumDef(BaseModel):
118
128
  title: str
119
129
  description: str = ""
130
+ locking: LockingConfig = Field(default_factory=LockingConfig)
120
131
  modules: list[Module]
121
132
 
122
133
  @model_validator(mode="after")
@@ -1,6 +1,7 @@
1
1
  <!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
2
2
  <!--
3
3
  Dispatcher component — renders the correct block component based on `block.type`.
4
+ Forwards child completion events upward as `onblockcomplete(blockIndex)`.
4
5
  -->
5
6
  <script lang="ts">
6
7
  import type { ContentBlock, QuizScore } from '$lib/types/index.js';
@@ -20,20 +21,28 @@
20
21
 
21
22
  interface Props {
22
23
  block: ContentBlock;
24
+ blockIndex: number;
25
+ onblockcomplete?: (blockIndex: number) => void;
23
26
  onquizcomplete?: (score: QuizScore) => void;
24
27
  }
25
- let { block, onquizcomplete }: Props = $props();
28
+ let { block, blockIndex, onblockcomplete, onquizcomplete }: Props = $props();
29
+
30
+ function handleBlockComplete() {
31
+ onblockcomplete?.(blockIndex);
32
+ }
26
33
  </script>
27
34
 
28
35
  {#if block.type === 'text'}
29
- <TextBlock content={block.content as TextContent} />
36
+ <TextBlock content={block.content as TextContent} ontextcomplete={handleBlockComplete} />
30
37
  {:else if block.type === 'video'}
31
- <VideoBlock content={block.content as VideoContent} />
38
+ <VideoBlock content={block.content as VideoContent} onvideocomplete={handleBlockComplete} />
32
39
  {:else if block.type === 'quiz'}
33
40
  <QuizBlock
34
41
  manifest={block.content as QuizManifest}
35
42
  quizRef={block.ref ?? ''}
43
+ passThreshold={(block.content as QuizManifest).passThreshold ?? 0.0}
36
44
  oncomplete={onquizcomplete}
45
+ onquizcomplete={handleBlockComplete}
37
46
  />
38
47
  {:else if block.type === 'exercise'}
39
48
  <ExerciseBlock content={block.content as ExerciseContent} />
@@ -2,19 +2,27 @@
2
2
  <script lang="ts">
3
3
  import { currentPosition, navigateTo } from '$lib/stores/curriculum.js';
4
4
  import type { Lesson, LessonProgress } from '$lib/types/index.js';
5
+ import { lessonStatusIcon, resolveLessonClick } from './module-list.helpers.js';
5
6
 
6
7
  interface Props {
7
8
  moduleId: string;
8
9
  lessons: Lesson[];
9
10
  progress?: Record<string, LessonProgress>;
11
+ optionalLessons?: Set<string>;
12
+ lockedLessons?: Set<string>;
10
13
  }
11
- let { moduleId, lessons, progress = {} }: Props = $props();
14
+ let {
15
+ moduleId,
16
+ lessons,
17
+ progress = {},
18
+ optionalLessons = new Set(),
19
+ lockedLessons = new Set()
20
+ }: Props = $props();
12
21
 
13
22
  function statusIcon(lessonId: string): string {
14
23
  const s = progress[lessonId]?.status;
15
- if (s === 'complete') return '✓';
16
- if (s === 'in_progress') return '…';
17
- return '○';
24
+ const concrete = s === 'optional' ? undefined : s;
25
+ return lessonStatusIcon(lessonId, concrete, optionalLessons);
18
26
  }
19
27
 
20
28
  function statusClass(lessonId: string): string {
@@ -23,21 +31,32 @@
23
31
  if (s === 'in_progress') return 'text-blue-500';
24
32
  return 'text-gray-400';
25
33
  }
34
+
35
+ function handleClick(lessonId: string) {
36
+ if (resolveLessonClick(lessonId, lockedLessons) === 'noop') return;
37
+ navigateTo(moduleId, lessonId);
38
+ }
26
39
  </script>
27
40
 
28
41
  <ul class="space-y-1">
29
42
  {#each lessons as lesson (lesson.id)}
30
43
  {@const isActive =
31
44
  $currentPosition?.moduleId === moduleId && $currentPosition?.lessonId === lesson.id}
45
+ {@const locked = lockedLessons.has(lesson.id)}
32
46
  <li>
33
47
  <button
34
- onclick={() => navigateTo(moduleId, lesson.id)}
48
+ onclick={() => handleClick(lesson.id)}
35
49
  class="flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors
36
- {isActive
37
- ? 'bg-blue-100 font-medium text-blue-700'
38
- : 'text-gray-700 hover:bg-gray-100'}"
50
+ {locked
51
+ ? 'cursor-not-allowed text-gray-300'
52
+ : isActive
53
+ ? 'bg-blue-100 font-medium text-blue-700'
54
+ : 'text-gray-700 hover:bg-gray-100'}"
55
+ aria-disabled={locked}
39
56
  >
40
- <span class="shrink-0 text-xs {statusClass(lesson.id)}">{statusIcon(lesson.id)}</span>
57
+ <span class="shrink-0 text-xs {locked ? 'text-gray-300' : statusClass(lesson.id)}"
58
+ >{statusIcon(lesson.id)}</span
59
+ >
41
60
  <span class="truncate">{lesson.title}</span>
42
61
  </button>
43
62
  </li>
@@ -0,0 +1,81 @@
1
+ <!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
2
+ <script lang="ts">
3
+ import { getLessonProgress, markLessonComplete, markLessonInProgress } from '$lib/db/index.js';
4
+ import { curriculum } from '$lib/stores/curriculum.js';
5
+ import { invalidateProgress } from '$lib/stores/progress.js';
6
+ import type { Lesson, QuizScore } from '$lib/types/index.js';
7
+ import ContentBlock from './ContentBlock.svelte';
8
+ import Navigation from './Navigation.svelte';
9
+ import { onMount } from 'svelte';
10
+
11
+ interface Props {
12
+ lesson: Lesson;
13
+ moduleId: string;
14
+ }
15
+ let { lesson, moduleId }: Props = $props();
16
+
17
+ let allBlocksComplete = $state(false);
18
+ let completedBlocks = $state(new Set<number>());
19
+
20
+ const lessonComplete = $derived(
21
+ allBlocksComplete || completedBlocks.size === lesson.content_blocks.length
22
+ );
23
+
24
+ onMount(async () => {
25
+ // Zero-block edge case
26
+ if (lesson.content_blocks.length === 0) {
27
+ allBlocksComplete = true;
28
+ await markLessonComplete(moduleId, lesson.id);
29
+ await invalidateProgress($curriculum);
30
+ return;
31
+ }
32
+
33
+ // Revisit: if lesson is already complete, pre-fill so nav is active
34
+ const existing = await getLessonProgress(moduleId, lesson.id);
35
+ if (existing?.status === 'complete') {
36
+ allBlocksComplete = true;
37
+ } else {
38
+ await markLessonInProgress(moduleId, lesson.id);
39
+ }
40
+ });
41
+
42
+ async function handleBlockComplete(blockIndex: number) {
43
+ if (allBlocksComplete) return;
44
+ completedBlocks.add(blockIndex);
45
+ completedBlocks = new Set(completedBlocks);
46
+ if (completedBlocks.size === lesson.content_blocks.length) {
47
+ await markLessonComplete(moduleId, lesson.id);
48
+ // Refreshing the progress store is enough to drive the
49
+ // `unlock_module_on_complete` cascade: locking utilities in
50
+ // `$lib/utils/locking.ts` re-derive sibling-optional and
51
+ // next-module-unlocked state from the new `complete` status —
52
+ // no additional DB write or extra invalidation is required.
53
+ await invalidateProgress($curriculum);
54
+ }
55
+ }
56
+
57
+ function handleQuizComplete(_score: QuizScore) {
58
+ // Score already persisted by QuizBlock; could trigger further logic here
59
+ }
60
+ </script>
61
+
62
+ <article class="mx-auto max-w-3xl space-y-8 py-6">
63
+ <header>
64
+ <h1 class="text-2xl font-bold text-gray-900">{lesson.title}</h1>
65
+ </header>
66
+
67
+ <div class="space-y-8">
68
+ {#each lesson.content_blocks as block, i (i)}
69
+ <section>
70
+ <ContentBlock
71
+ {block}
72
+ blockIndex={i}
73
+ onblockcomplete={handleBlockComplete}
74
+ onquizcomplete={handleQuizComplete}
75
+ />
76
+ </section>
77
+ {/each}
78
+ </div>
79
+
80
+ <Navigation disabled={!lessonComplete} />
81
+ </article>
@@ -0,0 +1,68 @@
1
+ // Copyright 2026 Pointmatic
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { describe, expect, it } from 'vitest';
4
+ import { createBlockTracker } from './lesson-view.helpers.js';
5
+
6
+ describe('LessonView block tracking (via createBlockTracker)', () => {
7
+ it('all blocks complete → returns true on final markBlockComplete', () => {
8
+ const tracker = createBlockTracker(3);
9
+
10
+ expect(tracker.markBlockComplete(0)).toBe(false);
11
+ expect(tracker.markBlockComplete(1)).toBe(false);
12
+ expect(tracker.markBlockComplete(2)).toBe(true);
13
+ expect(tracker.allComplete).toBe(true);
14
+ });
15
+
16
+ it('button disabled until all blocks done (allComplete is false)', () => {
17
+ const tracker = createBlockTracker(2);
18
+
19
+ expect(tracker.allComplete).toBe(false);
20
+ tracker.markBlockComplete(0);
21
+ expect(tracker.allComplete).toBe(false);
22
+ tracker.markBlockComplete(1);
23
+ expect(tracker.allComplete).toBe(true);
24
+ });
25
+
26
+ it('pre-fills set when lesson is already complete (revisit)', () => {
27
+ const tracker = createBlockTracker(5, true);
28
+
29
+ // Immediately complete — no engagement required
30
+ expect(tracker.allComplete).toBe(true);
31
+ expect(tracker.completedCount).toBe(5);
32
+ expect(tracker.markBlockComplete(0)).toBe(true);
33
+ });
34
+
35
+ it('zero-block lesson is immediately complete', () => {
36
+ const tracker = createBlockTracker(0);
37
+
38
+ expect(tracker.allComplete).toBe(true);
39
+ expect(tracker.completedCount).toBe(0);
40
+ });
41
+
42
+ it('duplicate markBlockComplete calls are idempotent', () => {
43
+ const tracker = createBlockTracker(2);
44
+
45
+ tracker.markBlockComplete(0);
46
+ tracker.markBlockComplete(0);
47
+ expect(tracker.completedCount).toBe(1);
48
+ expect(tracker.allComplete).toBe(false);
49
+
50
+ tracker.markBlockComplete(1);
51
+ expect(tracker.allComplete).toBe(true);
52
+ });
53
+
54
+ it('completedCount tracks incremental progress', () => {
55
+ const tracker = createBlockTracker(4);
56
+
57
+ expect(tracker.completedCount).toBe(0);
58
+ tracker.markBlockComplete(2);
59
+ expect(tracker.completedCount).toBe(1);
60
+ tracker.markBlockComplete(0);
61
+ expect(tracker.completedCount).toBe(2);
62
+ tracker.markBlockComplete(3);
63
+ expect(tracker.completedCount).toBe(3);
64
+ tracker.markBlockComplete(1);
65
+ expect(tracker.completedCount).toBe(4);
66
+ expect(tracker.allComplete).toBe(true);
67
+ });
68
+ });