learningfoundry 0.40.0__tar.gz → 0.46.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 (121) hide show
  1. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/CHANGELOG.md +71 -0
  2. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/PKG-INFO +38 -2
  3. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/README.md +37 -1
  4. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/pyproject.toml +1 -1
  5. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/__init__.py +1 -1
  6. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/config.py +15 -0
  7. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/resolver.py +13 -0
  8. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/schema_v1.py +11 -0
  9. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/e2e/navigation.spec.ts +30 -0
  10. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +24 -0
  11. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/e2e/video.spec.ts +27 -0
  12. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/package.json +2 -0
  13. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/playwright.config.ts +27 -0
  14. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +12 -3
  15. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.svelte +66 -0
  16. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +82 -0
  17. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +117 -0
  18. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +103 -0
  19. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/Navigation.svelte +14 -11
  20. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +47 -22
  21. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +118 -0
  22. {learningfoundry-0.40.0 → learningfoundry-0.46.0/src/learningfoundry}/sveltekit_template/src/lib/components/QuizBlock.svelte +8 -1
  23. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +51 -0
  24. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +78 -0
  25. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +136 -0
  26. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +96 -0
  27. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +63 -0
  28. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/module-list.helpers.ts +105 -0
  29. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/module-list.test.ts +65 -0
  30. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/navigation.helpers.ts +33 -0
  31. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/navigation.test.ts +43 -0
  32. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +39 -0
  33. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +11 -0
  34. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +98 -0
  35. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +28 -0
  36. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +12 -1
  37. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +238 -0
  38. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/utils/locking.ts +147 -0
  39. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +64 -0
  40. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +12 -18
  41. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +2 -22
  42. {learningfoundry-0.40.0 → learningfoundry-0.46.0/src/learningfoundry}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +6 -4
  43. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +93 -0
  44. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/ContentBlock.svelte +12 -3
  45. learningfoundry-0.46.0/sveltekit_template/src/lib/components/LessonList.svelte +66 -0
  46. learningfoundry-0.46.0/sveltekit_template/src/lib/components/LessonView.svelte +82 -0
  47. learningfoundry-0.46.0/sveltekit_template/src/lib/components/ModuleList.svelte +103 -0
  48. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/Navigation.svelte +14 -11
  49. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/ProgressDashboard.svelte +47 -22
  50. {learningfoundry-0.40.0/src/learningfoundry → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/QuizBlock.svelte +8 -1
  51. learningfoundry-0.46.0/sveltekit_template/src/lib/components/TextBlock.svelte +51 -0
  52. learningfoundry-0.46.0/sveltekit_template/src/lib/components/VideoBlock.svelte +136 -0
  53. learningfoundry-0.46.0/sveltekit_template/src/lib/components/lesson-view.helpers.ts +63 -0
  54. learningfoundry-0.46.0/sveltekit_template/src/lib/components/module-list.helpers.ts +105 -0
  55. learningfoundry-0.46.0/sveltekit_template/src/lib/components/navigation.helpers.ts +33 -0
  56. learningfoundry-0.46.0/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +39 -0
  57. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/stores/curriculum.ts +10 -0
  58. learningfoundry-0.46.0/sveltekit_template/src/lib/stores/progress.ts +28 -0
  59. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/types/index.ts +12 -1
  60. learningfoundry-0.46.0/sveltekit_template/src/lib/utils/locking.ts +147 -0
  61. learningfoundry-0.46.0/sveltekit_template/src/lib/utils/viewport-completion.ts +64 -0
  62. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/routes/+layout.svelte +12 -19
  63. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/routes/+page.svelte +2 -22
  64. {learningfoundry-0.40.0/src/learningfoundry → learningfoundry-0.46.0}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +6 -18
  65. learningfoundry-0.40.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.svelte +0 -45
  66. learningfoundry-0.40.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +0 -44
  67. learningfoundry-0.40.0/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +0 -67
  68. learningfoundry-0.40.0/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +0 -16
  69. learningfoundry-0.40.0/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -45
  70. learningfoundry-0.40.0/sveltekit_template/src/lib/components/LessonList.svelte +0 -45
  71. learningfoundry-0.40.0/sveltekit_template/src/lib/components/LessonView.svelte +0 -44
  72. learningfoundry-0.40.0/sveltekit_template/src/lib/components/ModuleList.svelte +0 -67
  73. learningfoundry-0.40.0/sveltekit_template/src/lib/components/TextBlock.svelte +0 -16
  74. learningfoundry-0.40.0/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -45
  75. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/.gitignore +0 -0
  76. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/LICENSE +0 -0
  77. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/docs/project-guide/README.md +0 -0
  78. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/__main__.py +0 -0
  79. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/asset_resolver.py +0 -0
  80. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/cli.py +0 -0
  81. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/exceptions.py +0 -0
  82. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/generator.py +0 -0
  83. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/__init__.py +0 -0
  84. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
  85. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
  86. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/protocols.py +0 -0
  87. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/quizazz.py +0 -0
  88. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/logging_config.py +0 -0
  89. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/parser.py +0 -0
  90. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/pipeline.py +0 -0
  91. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/py.typed +0 -0
  92. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
  93. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
  94. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
  95. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  96. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  97. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  98. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  99. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
  100. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +0 -0
  101. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/db/progress.ts +0 -0
  102. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
  103. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
  104. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  105. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
  106. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
  107. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
  108. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
  109. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
  110. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
  111. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
  112. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/app.css +0 -0
  113. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/app.html +0 -0
  114. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  115. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  116. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  117. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  118. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/db/database.ts +0 -0
  119. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/db/index.ts +0 -0
  120. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/db/progress.ts +0 -0
  121. {learningfoundry-0.40.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/utils/markdown.ts +0 -0
@@ -7,6 +7,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.46.0] - 2026-05-01
11
+
12
+ ### Fixed
13
+
14
+ - **Lesson navigation routing.** Sidebar lesson clicks, the Next/Finish button, and dashboard "Start module / Continue" buttons now call `goto()` from `$app/navigation` directly instead of going through the curriculum store helper. The previous flow updated `currentPosition` (and therefore the sidebar highlight) but left the URL untouched, so the lesson route was never re-mounted: `markLessonInProgress` ran only on the first lesson reached by direct URL, the sidebar checkmarks never updated, and the curriculum/module progress bars stayed at zero across the session. Components now route via `Navigation.svelte`, `LessonList.svelte`, `ProgressDashboard.svelte` → `goto('/${moduleId}/${lessonId}')`.
15
+ - **Sticky LessonView state across navigations.** The dynamic lesson route now wraps `<LessonView>` in `{#key \`${moduleId}/${lessonId}\`}` so the subtree tears down and re-mounts whenever either route param changes — guaranteeing fresh `allBlocksComplete` / `completedBlocks` state and a re-run of the on-mount progress check (so revisiting a previously-completed lesson activates Next/Finish immediately).
16
+ - **Stale video iframe across consecutive video lessons.** `LessonView`'s `{#each lesson.content_blocks}` now uses a stable identity key derived from `block.ref` or `block.content.url` (falling back to `${type}-${index}`); previously, two consecutive lessons each with a `video` block reused the same `<VideoBlock>` instance and its iframe player, leaving the previous lesson's video on screen. `VideoBlock.svelte` additionally tracks `content.url` via `$effect` and tears down / recreates its YouTube player whenever the URL changes, as belt-and-suspenders coverage.
17
+
18
+ ### Added
19
+
20
+ - **Playwright e2e harness.** New `e2e/` directory with three regression specs (`navigation.spec.ts`, `progress.spec.ts`, `video.spec.ts`) covering the FR-P9/FR-P10 lifecycle invariants that vitest cannot exercise (because vitest mocks `$app/navigation`). New `pnpm e2e` script and `playwright.config.ts` driving `pnpm preview` against the built static site. The smoke test runs `pnpm e2e` after `pnpm build` and skips gracefully if Playwright browsers aren't installed locally; CI installs them via `pnpm exec playwright install chromium`.
21
+ - **Vitest navigation regression coverage.** New `navigation.helpers.ts` (`resolveGoNext`, `resolveGoPrev`, `lessonHref`) and `navigation.test.ts` lock down the routing decisions made by Next/Finish, Previous, and lesson rows. New `contentBlockKey` helper (and tests) verifies the FR-P10 stable-identity convention used by `{#each}`.
22
+
23
+ ### Changed
24
+
25
+ - `navigateTo` in `$lib/stores/curriculum.ts` is now documented as **internal route-sync only — UI code must use `goto` directly**. The function continues to set `currentPosition` for use by the dynamic lesson route's URL→store `$effect`; UI code (sidebar, dashboard, Next/Finish) routes via `goto()` so SvelteKit's full navigation lifecycle (page params, scroll restoration, `{#key}` re-mount) fires predictably.
26
+
27
+ ## [0.45.0] - 2026-04-30
28
+
29
+ ### Added
30
+
31
+ - **Locking and unlocking UI.** Frontend implementation of the locking model introduced in v0.44.0:
32
+ - New `$lib/utils/locking.ts` with pure functions `isModuleLocked`, `isLessonLocked`, `getOptionalLessons`, `isModuleComplete`, plus convenience set helpers `lockedModuleIds` / `lockedLessonIds`.
33
+ - `ModuleList.svelte` renders a Lucide `Lock` icon, suppresses expansion, and skips the active-module highlight for locked modules.
34
+ - `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.
35
+ - `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.
36
+ - Type extensions: `LessonStatus` gains `'optional'`; `Lesson.unlock_module_on_complete?`, `Module.locked?`, `Curriculum.locking?`, and a new `LockingConfig` interface.
37
+ - 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.
38
+
39
+ ## [0.44.0] - 2026-04-30
40
+
41
+ ### Added
42
+
43
+ - **Locking configuration schema.** Python-side schema, resolver, and config support for sequential content access control:
44
+ - `LockingConfig` Pydantic model (`sequential`, `lesson_sequential`) on `CurriculumDef`.
45
+ - `Module.locked: bool | None` per-module override.
46
+ - `Lesson.unlock_module_on_complete: bool` gateway-lesson flag.
47
+ - `QuizBlock.pass_threshold: float` (0.0–1.0) for quiz completion scoring.
48
+ - Global config (`~/.config/learningfoundry/config.yml`) gains `locking` block with the same fields.
49
+ - Config hierarchy: global defaults → curriculum YAML `locking` → per-module `locked` override.
50
+ - All fields propagate through the resolver into `curriculum.json` for frontend consumption (Story I.j).
51
+
52
+ ## [0.43.0] - 2026-04-30
53
+
54
+ ### Added
55
+
56
+ - **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.
57
+
58
+ ## [0.42.0] - 2026-04-30
59
+
60
+ ### Added
61
+
62
+ - **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.
63
+ - **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.
64
+ - **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.
65
+ - **Zero-block edge case.** A lesson with no content blocks is treated as immediately complete on mount.
66
+ - **`QuizManifest.passThreshold`** added to TypeScript types for future quiz scoring threshold support.
67
+
68
+ ### Changed
69
+
70
+ - **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`.
71
+ - **`+page.svelte`** migrated from deprecated `$app/stores` to `$app/state` for route params.
72
+
73
+ ## [0.41.0] - 2026-04-30
74
+
75
+ ### Fixed
76
+
77
+ - **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.
78
+ - **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.
79
+ - **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`).
80
+
10
81
  ## [0.40.0] - 2026-04-29
11
82
 
12
83
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learningfoundry
3
- Version: 0.40.0
3
+ Version: 0.46.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.46.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.46.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")
@@ -0,0 +1,30 @@
1
+ // Copyright 2026 Pointmatic
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // FR-P9 regression coverage: in-app navigation must update the URL.
5
+ // Before v0.46.0 the sidebar / Next button updated `currentPosition` but
6
+ // not the route, so `LessonView` was never re-mounted on lesson change.
7
+ import { expect, test } from '@playwright/test';
8
+
9
+ test.describe('lesson navigation routing', () => {
10
+ test('sidebar lesson click updates URL', async ({ page }) => {
11
+ await page.goto('/');
12
+ // Open the first module by clicking its header in the sidebar.
13
+ const firstModuleHeader = page.locator('aside nav button').first();
14
+ await firstModuleHeader.click();
15
+
16
+ // Click the first lesson row in the now-expanded module.
17
+ const firstLessonRow = page.locator('aside nav ul ul button').first();
18
+ await firstLessonRow.click();
19
+
20
+ // URL must reflect the click; before v0.46.0 it stayed at "/".
21
+ await expect(page).toHaveURL(/\/[^/]+\/[^/]+$/);
22
+ });
23
+
24
+ test('dashboard "Start module" deep-links into a lesson', async ({ page }) => {
25
+ await page.goto('/');
26
+ const startBtn = page.getByRole('button', { name: /start module|continue/i }).first();
27
+ await startBtn.click();
28
+ await expect(page).toHaveURL(/\/[^/]+\/[^/]+$/);
29
+ });
30
+ });
@@ -0,0 +1,24 @@
1
+ // Copyright 2026 Pointmatic
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // FR-P9 regression: progress reactivity. Reaching a lesson page must mark
5
+ // it `in_progress` and the sidebar status icon must reflect that on the
6
+ // next sidebar render — without a page reload.
7
+ import { expect, test } from '@playwright/test';
8
+
9
+ test.describe('progress reactivity', () => {
10
+ test('navigating into a lesson marks it in_progress in the sidebar', async ({ page }) => {
11
+ await page.goto('/');
12
+
13
+ // Expand the first module and click into its first lesson.
14
+ await page.locator('aside nav button').first().click();
15
+ await page.locator('aside nav ul ul button').first().click();
16
+ await expect(page).toHaveURL(/\/[^/]+\/[^/]+$/);
17
+
18
+ // Sidebar status icons: ○ = not_started, … = in_progress, ✓ = complete.
19
+ // After landing on the lesson page, the active row should not still
20
+ // show ○ (regression check; the previous bug left status forever ○).
21
+ const activeIcon = page.locator('aside nav ul ul button.bg-blue-100 span').first();
22
+ await expect(activeIcon).not.toHaveText('○');
23
+ });
24
+ });
@@ -0,0 +1,27 @@
1
+ // Copyright 2026 Pointmatic
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // FR-P10 regression coverage: when navigating between two lessons that
5
+ // each contain a video block, only the new lesson's iframe should be
6
+ // present — the previous player must be destroyed. The {#key} wrapper
7
+ // in `[module]/[lesson]/+page.svelte` plus the stable block key in
8
+ // `LessonView` enforce this.
9
+ import { expect, test } from '@playwright/test';
10
+
11
+ test.describe('video block lifecycle', () => {
12
+ test('lesson page renders at most one YouTube iframe per video block', async ({ page }) => {
13
+ await page.goto('/');
14
+ await page.locator('aside nav button').first().click();
15
+ await page.locator('aside nav ul ul button').first().click();
16
+ await expect(page).toHaveURL(/\/[^/]+\/[^/]+$/);
17
+
18
+ // Wait briefly for the YouTube IFrame API to upgrade the placeholder
19
+ // `<div id="yt-player-…">` into an `<iframe>`. If the API is blocked
20
+ // (no network in CI), there will be zero iframes — that's still a
21
+ // pass for "no leaked iframes from a prior lesson", which is what
22
+ // the regression cares about.
23
+ await page.waitForTimeout(2000);
24
+ const iframes = await page.locator('iframe[src*="youtube"]').count();
25
+ expect(iframes).toBeLessThanOrEqual(1);
26
+ });
27
+ });
@@ -9,6 +9,7 @@
9
9
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
10
10
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
11
11
  "test": "vitest run",
12
+ "e2e": "playwright test",
12
13
  "postinstall": "node -e \"const fs=require('fs'); fs.mkdirSync('static',{recursive:true}); fs.copyFileSync(require.resolve('sql.js/dist/sql-wasm.wasm'),'static/sql-wasm.wasm')\""
13
14
  },
14
15
  "dependencies": {
@@ -21,6 +22,7 @@
21
22
  "svelte": "^5.0.0"
22
23
  },
23
24
  "devDependencies": {
25
+ "@playwright/test": "^1.48.0",
24
26
  "@sveltejs/adapter-static": "^3.0.0",
25
27
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
26
28
  "@tailwindcss/typography": "^0.5.16",
@@ -0,0 +1,27 @@
1
+ // Copyright 2026 Pointmatic
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { defineConfig, devices } from '@playwright/test';
4
+
5
+ const PORT = Number(process.env.PLAYWRIGHT_PORT ?? 4173);
6
+
7
+ export default defineConfig({
8
+ testDir: './e2e',
9
+ fullyParallel: false,
10
+ reporter: [['list']],
11
+ use: {
12
+ baseURL: `http://localhost:${PORT}`,
13
+ trace: 'retain-on-failure'
14
+ },
15
+ webServer: {
16
+ command: `pnpm preview --port ${PORT} --strictPort`,
17
+ port: PORT,
18
+ reuseExistingServer: !process.env.CI,
19
+ timeout: 60_000
20
+ },
21
+ projects: [
22
+ {
23
+ name: 'chromium',
24
+ use: { ...devices['Desktop Chrome'] }
25
+ }
26
+ ]
27
+ });
@@ -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} />
@@ -0,0 +1,66 @@
1
+ <!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
2
+ <script lang="ts">
3
+ import { goto } from '$app/navigation';
4
+ import { currentPosition } from '$lib/stores/curriculum.js';
5
+ import type { Lesson, LessonProgress } from '$lib/types/index.js';
6
+ import { lessonStatusIcon, resolveLessonClick } from './module-list.helpers.js';
7
+ import { lessonHref } from './navigation.helpers.js';
8
+
9
+ interface Props {
10
+ moduleId: string;
11
+ lessons: Lesson[];
12
+ progress?: Record<string, LessonProgress>;
13
+ optionalLessons?: Set<string>;
14
+ lockedLessons?: Set<string>;
15
+ }
16
+ let {
17
+ moduleId,
18
+ lessons,
19
+ progress = {},
20
+ optionalLessons = new Set(),
21
+ lockedLessons = new Set()
22
+ }: Props = $props();
23
+
24
+ function statusIcon(lessonId: string): string {
25
+ const s = progress[lessonId]?.status;
26
+ const concrete = s === 'optional' ? undefined : s;
27
+ return lessonStatusIcon(lessonId, concrete, optionalLessons);
28
+ }
29
+
30
+ function statusClass(lessonId: string): string {
31
+ const s = progress[lessonId]?.status;
32
+ if (s === 'complete') return 'text-green-600';
33
+ if (s === 'in_progress') return 'text-blue-500';
34
+ return 'text-gray-400';
35
+ }
36
+
37
+ function handleClick(lessonId: string) {
38
+ if (resolveLessonClick(lessonId, lockedLessons) === 'noop') return;
39
+ void goto(lessonHref(moduleId, lessonId));
40
+ }
41
+ </script>
42
+
43
+ <ul class="space-y-1">
44
+ {#each lessons as lesson (lesson.id)}
45
+ {@const isActive =
46
+ $currentPosition?.moduleId === moduleId && $currentPosition?.lessonId === lesson.id}
47
+ {@const locked = lockedLessons.has(lesson.id)}
48
+ <li>
49
+ <button
50
+ onclick={() => handleClick(lesson.id)}
51
+ class="flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors
52
+ {locked
53
+ ? 'cursor-not-allowed text-gray-300'
54
+ : isActive
55
+ ? 'bg-blue-100 font-medium text-blue-700'
56
+ : 'text-gray-700 hover:bg-gray-100'}"
57
+ aria-disabled={locked}
58
+ >
59
+ <span class="shrink-0 text-xs {locked ? 'text-gray-300' : statusClass(lesson.id)}"
60
+ >{statusIcon(lesson.id)}</span
61
+ >
62
+ <span class="truncate">{lesson.title}</span>
63
+ </button>
64
+ </li>
65
+ {/each}
66
+ </ul>