learningfoundry 0.49.0__tar.gz → 0.52.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 (145) hide show
  1. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/CHANGELOG.md +36 -0
  2. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/PKG-INFO +1 -1
  3. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/pyproject.toml +1 -1
  4. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/__init__.py +1 -1
  5. learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/e2e/README.md +47 -0
  6. learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/e2e/fixtures/curriculum.json +77 -0
  7. learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/e2e/lifecycle.spec.ts +51 -0
  8. learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +98 -0
  9. learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/e2e/text-block-bottom.spec.ts +57 -0
  10. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/package.json +2 -0
  11. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.svelte +4 -1
  12. learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +145 -0
  13. learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +227 -0
  14. learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.observer.test.ts +93 -0
  15. {learningfoundry-0.49.0 → learningfoundry-0.52.0/src/learningfoundry}/sveltekit_template/src/lib/components/TextBlock.svelte +7 -4
  16. {learningfoundry-0.49.0 → learningfoundry-0.52.0/src/learningfoundry}/sveltekit_template/src/lib/components/module-list.helpers.ts +6 -2
  17. {learningfoundry-0.49.0 → learningfoundry-0.52.0/src/learningfoundry}/sveltekit_template/src/lib/components/module-list.test.ts +4 -0
  18. learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/src/lib/components/mount.test.ts +26 -0
  19. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +1 -0
  20. learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/src/lib/db/progress.test.ts +107 -0
  21. {learningfoundry-0.49.0 → learningfoundry-0.52.0/src/learningfoundry}/sveltekit_template/src/lib/db/progress.ts +27 -0
  22. {learningfoundry-0.49.0 → learningfoundry-0.52.0/src/learningfoundry}/sveltekit_template/src/lib/types/index.ts +11 -1
  23. {learningfoundry-0.49.0 → learningfoundry-0.52.0/src/learningfoundry}/sveltekit_template/src/lib/utils/locking.ts +4 -0
  24. {learningfoundry-0.49.0 → learningfoundry-0.52.0/src/learningfoundry}/sveltekit_template/src/lib/utils/progress.test.ts +10 -0
  25. learningfoundry-0.52.0/src/learningfoundry/sveltekit_template/vite.config.ts +22 -0
  26. learningfoundry-0.52.0/sveltekit_template/e2e/README.md +47 -0
  27. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/LessonList.svelte +4 -1
  28. learningfoundry-0.52.0/sveltekit_template/src/lib/components/LessonView.svelte +145 -0
  29. learningfoundry-0.52.0/sveltekit_template/src/lib/components/LessonView.test.ts +227 -0
  30. learningfoundry-0.52.0/sveltekit_template/src/lib/components/TextBlock.observer.test.ts +93 -0
  31. {learningfoundry-0.49.0/src/learningfoundry → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/TextBlock.svelte +7 -4
  32. {learningfoundry-0.49.0/src/learningfoundry → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/module-list.helpers.ts +6 -2
  33. {learningfoundry-0.49.0/src/learningfoundry → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/module-list.test.ts +4 -0
  34. learningfoundry-0.52.0/sveltekit_template/src/lib/components/mount.test.ts +26 -0
  35. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/db/index.ts +1 -0
  36. learningfoundry-0.52.0/sveltekit_template/src/lib/db/progress.test.ts +107 -0
  37. {learningfoundry-0.49.0/src/learningfoundry → learningfoundry-0.52.0}/sveltekit_template/src/lib/db/progress.ts +27 -0
  38. {learningfoundry-0.49.0/src/learningfoundry → learningfoundry-0.52.0}/sveltekit_template/src/lib/types/index.ts +11 -1
  39. {learningfoundry-0.49.0/src/learningfoundry → learningfoundry-0.52.0}/sveltekit_template/src/lib/utils/locking.ts +4 -0
  40. {learningfoundry-0.49.0/src/learningfoundry → learningfoundry-0.52.0}/sveltekit_template/src/lib/utils/progress.test.ts +10 -0
  41. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +0 -24
  42. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/e2e/text-block-bottom.spec.ts +0 -27
  43. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +0 -82
  44. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +0 -117
  45. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/src/lib/db/progress.test.ts +0 -44
  46. learningfoundry-0.49.0/src/learningfoundry/sveltekit_template/vite.config.ts +0 -15
  47. learningfoundry-0.49.0/sveltekit_template/src/lib/components/LessonView.svelte +0 -82
  48. learningfoundry-0.49.0/sveltekit_template/src/lib/db/progress.test.ts +0 -44
  49. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/.gitignore +0 -0
  50. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/LICENSE +0 -0
  51. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/README.md +0 -0
  52. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/docs/project-guide/README.md +0 -0
  53. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/__main__.py +0 -0
  54. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/asset_resolver.py +0 -0
  55. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/cli.py +0 -0
  56. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/config.py +0 -0
  57. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/exceptions.py +0 -0
  58. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/generator.py +0 -0
  59. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/integrations/__init__.py +0 -0
  60. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
  61. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
  62. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/integrations/protocols.py +0 -0
  63. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/integrations/quizazz.py +0 -0
  64. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/logging_config.py +0 -0
  65. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/parser.py +0 -0
  66. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/pipeline.py +0 -0
  67. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/py.typed +0 -0
  68. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/resolver.py +0 -0
  69. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/schema_v1.py +0 -0
  70. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/e2e/finish.spec.ts +0 -0
  71. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/e2e/navigation.spec.ts +0 -0
  72. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/e2e/reset.spec.ts +0 -0
  73. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/e2e/video.spec.ts +0 -0
  74. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/playwright.config.ts +0 -0
  75. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
  76. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
  77. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
  78. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
  79. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  80. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
  81. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/Navigation.svelte +0 -0
  82. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  83. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  84. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
  85. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +0 -0
  86. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
  87. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.svelte +0 -0
  88. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.test.ts +0 -0
  89. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +0 -0
  90. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
  91. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +0 -0
  92. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  93. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +0 -0
  94. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.helpers.ts +0 -0
  95. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.test.ts +0 -0
  96. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
  97. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
  98. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
  99. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
  100. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +0 -0
  101. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +0 -0
  102. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +0 -0
  103. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
  104. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  105. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/utils/progress.ts +0 -0
  106. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
  107. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +0 -0
  108. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
  109. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
  110. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -0
  111. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
  112. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
  113. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +0 -0
  114. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
  115. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
  116. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
  117. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/app.css +0 -0
  118. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/app.html +0 -0
  119. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
  120. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  121. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
  122. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/Navigation.svelte +0 -0
  123. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  124. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  125. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
  126. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
  127. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/ResetCourseButton.svelte +0 -0
  128. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/ResetCourseButton.test.ts +0 -0
  129. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/TextBlock.test.ts +0 -0
  130. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
  131. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  132. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/lesson-view.helpers.ts +0 -0
  133. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/navigation.helpers.ts +0 -0
  134. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/navigation.test.ts +0 -0
  135. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
  136. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/db/database.ts +0 -0
  137. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
  138. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/stores/progress.ts +0 -0
  139. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  140. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/utils/progress.ts +0 -0
  141. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
  142. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/routes/+layout.svelte +0 -0
  143. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/routes/+page.svelte +0 -0
  144. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -0
  145. {learningfoundry-0.49.0 → learningfoundry-0.52.0}/sveltekit_template/src/routes/layout.test.ts +0 -0
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.52.0] - 2026-05-01
11
+
12
+ ### Added
13
+
14
+ - **Svelte 5 component mount support in vitest** (Story I.q). Component tests can now `render(...)` from `@testing-library/svelte` directly, replacing the source-text and helper-only workarounds used in v0.50.0 and v0.51.0. New [mount.test.ts](src/learningfoundry/sveltekit_template/src/lib/components/mount.test.ts) smoke fails loudly if the config silently reverts. [TextBlock.observer.test.ts](src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.observer.test.ts) rewritten to mount the real component, stub `IntersectionObserver`, capture the observed element, and assert it is the sentinel with non-zero inline height. One previously-deferred I.p case re-instated in [LessonView.test.ts](src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts): asserts `markLessonOpened` resolves before `onlessonopen` fires (lifecycle ordering contract).
15
+ - `@testing-library/svelte` and `@testing-library/jest-dom` dev dependencies.
16
+
17
+ ### Changed
18
+
19
+ - `vite.config.ts` adds `resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined`. Vitest pulls Svelte's browser entry so `mount(...)` works in jsdom; production `vite build` is unaffected (the conditions block is gated on the env var). Documented in `project-essentials.md` under a new "Testing" subsection so the guard isn't stripped in a future "simplify".
20
+
21
+ ## [0.51.0] - 2026-05-01
22
+
23
+ ### Added
24
+
25
+ - **Lesson `opened` status and three lifecycle event hooks** (Story I.p / FR-P15). `LessonStatus` now runs `not_started → opened → in_progress → complete` (plus the orthogonal `optional`). `LessonView` mounts call new `markLessonOpened` DB op (upgrade-only — never demotes a more advanced status), then dispatch `onlessonopen`. `markLessonInProgress` and `onlessonengage` now fire on the *first* block-completion event of the mount session — not on mount itself — so a learner who opens a lesson but engages with no content is distinguishable from one genuinely partway through. `onlessoncomplete` fires after `markLessonComplete` succeeds. Revisits to a `complete` lesson fire `onlessonopen` only (no engage / complete events when no transition occurs); zero-block lessons fire `onlessonopen` then `onlessoncomplete` in order. No internal subscribers exist today — the events are forward-compatible hooks for future analytics / telemetry adapters.
26
+
27
+ ### Changed
28
+
29
+ - `markLessonInProgress` is now invoked on the first block-engagement event rather than on mount. SQL itself is unchanged.
30
+ - Sidebar icon mapping broadened: `opened` shows the same `…` icon (and `text-blue-500` class) as `in_progress`. The lifecycle distinction is data-only — learners see the same "started" symbol regardless of engagement, by design (FR-P15 / Q2).
31
+ - `getModuleProgress`'s module-status derivation: `opened` falls into the `s !== 'not_started'` branch and surfaces as `in_progress` at the module level (intentional; one-line comment added).
32
+
33
+ ## [0.50.0] - 2026-05-01
34
+
35
+ ### Fixed
36
+
37
+ - **Text-block completion regression introduced in v0.48.0.** The end-of-block sentinel was rendered with `height: 0`, causing `IntersectionObserver` to compute `intersectionRatio = 0` against the configured `0.1` threshold and the `isIntersecting` branch to never fire in real browsers. Net effect: lessons were never marked complete (no sidebar `✓`, no module % movement, no curriculum-bar movement), and revisits couldn't pre-fill the Next/Finish enabled state. The sentinel now renders as `<div data-textblock-end style="height: 1px">` — invisible to learners but observable by the browser. The vitest helper-only suite was unchanged by the regression because it never instantiated a real observer; the e2e harness was unchanged because the spec asserted only sentinel presence rather than actual completion.
38
+
39
+ ### Added
40
+
41
+ - **TextBlock sentinel anti-regression vitest coverage** ([TextBlock.observer.test.ts](src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.observer.test.ts)): three source-template assertions covering the v0.48.0 zero-area trap — sentinel exists with `data-textblock-end`, carries inline `style="height: 1px"`, and is the element passed to `observer.observe()`. Source-text assertions are brittle to formatting but reliable; mounting Svelte 5 components in vitest (via `@testing-library/svelte` or `svelte/server`) collided with the SvelteKit vite plugin's client-mode compilation, so the canonical cross-check that the markup actually behaves is the e2e harness.
42
+ - **Lesson-completion e2e tests** ([progress.spec.ts](src/learningfoundry/sveltekit_template/e2e/progress.spec.ts)): three new cases exercising the FR-P11 user-visible outcome — short-text-block lesson transitions to `✓` in the sidebar without reload; dashboard "X of N completed" increments after completion; revisiting a complete lesson pre-fills Next/Finish as enabled.
43
+ - **Tall-text-block scroll-to-complete e2e tests** ([text-block-bottom.spec.ts](src/learningfoundry/sveltekit_template/e2e/text-block-bottom.spec.ts)): rewritten from the prior "structural existence" check. Tall lesson does NOT complete without scroll; scrolling `<main>` to the bottom triggers `✓` within 2 s.
44
+ - **Dedicated e2e curriculum fixture** ([e2e/fixtures/curriculum.json](src/learningfoundry/sveltekit_template/e2e/fixtures/curriculum.json) + [e2e/README.md](src/learningfoundry/sveltekit_template/e2e/README.md)): self-contained 3-lesson fixture covering short-text completion and tall-text scroll-to-complete. Specs install a `page.route('**/curriculum.json', …)` interception in `beforeEach` so the harness is decoupled from the smoke build's curriculum drift.
45
+
10
46
  ## [0.49.0] - 2026-05-01
11
47
 
12
48
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learningfoundry
3
- Version: 0.49.0
3
+ Version: 0.52.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "learningfoundry"
7
- version = "0.49.0"
7
+ version = "0.52.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.49.0"
4
+ __version__ = "0.52.0"
@@ -0,0 +1,47 @@
1
+ # Playwright e2e tests
2
+
3
+ These tests run against the static `pnpm preview` server (see
4
+ `playwright.config.ts`) and exercise the navigation/completion lifecycle
5
+ that vitest cannot reach because vitest mocks `$app/navigation` and
6
+ `IntersectionObserver`.
7
+
8
+ ## Fixture curriculum
9
+
10
+ `fixtures/curriculum.json` is a hand-authored, self-contained curriculum
11
+ matching the runtime shape that `learningfoundry build` emits. The
12
+ specs install a `page.route('**/curriculum.json', …)` interception in
13
+ `beforeEach` so the app loads the fixture instead of whatever
14
+ `curriculum.json` happens to live in `static/` from the smoke build —
15
+ that decoupling keeps these tests stable against unrelated curriculum
16
+ edits.
17
+
18
+ The fixture contains three lessons:
19
+
20
+ - `mod-01/lesson-01` — short text block; sentinel fits in the
21
+ viewport on first render and `textcomplete` fires after 1 s.
22
+ - `mod-01/lesson-02` — tall text block (`200vh` spacer) where the
23
+ sentinel only intersects after `<main>` scrolls to the bottom;
24
+ completion must not fire before scroll and must fire within 2 s of
25
+ it.
26
+ - `mod-02/lesson-01` — trailing short lesson, gives navigation
27
+ sequences a destination.
28
+
29
+ ## Regenerating the fixture
30
+
31
+ The fixture was hand-authored to keep it tightly scoped to the
32
+ behaviours the specs care about. If you ever want to derive it from
33
+ YAML so it shares the validation path with author-facing curricula,
34
+ write a tiny `learningfoundry build` driver into `e2e/fixtures/yaml/`
35
+ and copy the resulting `curriculum.json` over this file. Until that
36
+ need shows up, hand-editing this file is the path of least surprise.
37
+
38
+ ## Running locally
39
+
40
+ ```bash
41
+ pnpm exec playwright install chromium # one-time
42
+ pnpm e2e
43
+ ```
44
+
45
+ The smoke test (`tests/test_smoke_sveltekit.py::test_pnpm_e2e_passes`)
46
+ runs the same suite after `pnpm build` and skips gracefully when the
47
+ Chromium browser is not installed.
@@ -0,0 +1,77 @@
1
+ {
2
+ "version": "1.0.0",
3
+ "title": "E2E Fixture",
4
+ "description": "Synthetic curriculum used by Playwright e2e tests to exercise lesson completion, sidebar reactivity, and tall-block scroll-to-complete without depending on the smoke build's ever-changing fixture.",
5
+ "locking": { "sequential": false, "lesson_sequential": false },
6
+ "assets": [],
7
+ "modules": [
8
+ {
9
+ "id": "mod-01",
10
+ "title": "Module One",
11
+ "description": "Short-text completion + tall-text scroll.",
12
+ "locked": null,
13
+ "pre_assessment": null,
14
+ "post_assessment": null,
15
+ "lessons": [
16
+ {
17
+ "id": "lesson-01",
18
+ "title": "Short Lesson",
19
+ "unlock_module_on_complete": false,
20
+ "content_blocks": [
21
+ {
22
+ "type": "text",
23
+ "source": null,
24
+ "ref": "content/mod-01/lesson-01.md",
25
+ "content": {
26
+ "markdown": "Hello. This is a short text block whose sentinel sits inside the viewport on first render, so it should fire `textcomplete` after the 1 s debounce.",
27
+ "path": "content/mod-01/lesson-01.md"
28
+ }
29
+ }
30
+ ]
31
+ },
32
+ {
33
+ "id": "lesson-02",
34
+ "title": "Tall Lesson",
35
+ "unlock_module_on_complete": false,
36
+ "content_blocks": [
37
+ {
38
+ "type": "text",
39
+ "source": null,
40
+ "ref": "content/mod-01/lesson-02.md",
41
+ "content": {
42
+ "markdown": "# Tall block\n\nThis lesson is long enough that the end-of-block sentinel sits below the fold on initial render.\n\n<div style=\"height: 200vh\"> </div>\n\nThe sentinel only intersects the viewport once `<main>` has been scrolled to the bottom — completion must NOT fire before that scroll, and it MUST fire within 2 s of reaching the bottom.",
43
+ "path": "content/mod-01/lesson-02.md"
44
+ }
45
+ }
46
+ ]
47
+ }
48
+ ]
49
+ },
50
+ {
51
+ "id": "mod-02",
52
+ "title": "Module Two",
53
+ "description": "Trailing module so the curriculum has at least three lessons total.",
54
+ "locked": null,
55
+ "pre_assessment": null,
56
+ "post_assessment": null,
57
+ "lessons": [
58
+ {
59
+ "id": "lesson-01",
60
+ "title": "Trailing Short Lesson",
61
+ "unlock_module_on_complete": false,
62
+ "content_blocks": [
63
+ {
64
+ "type": "text",
65
+ "source": null,
66
+ "ref": "content/mod-02/lesson-01.md",
67
+ "content": {
68
+ "markdown": "Trailing lesson body. Provides a destination for navigation tests.",
69
+ "path": "content/mod-02/lesson-01.md"
70
+ }
71
+ }
72
+ ]
73
+ }
74
+ ]
75
+ }
76
+ ]
77
+ }
@@ -0,0 +1,51 @@
1
+ // Copyright 2026 Pointmatic
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // FR-P15 / Story I.p — lesson lifecycle visual sequence.
5
+ //
6
+ // The data sequence underneath is:
7
+ // not_started → opened (mount) → in_progress (first block engage) → complete
8
+ // The visual sequence the learner sees is:
9
+ // ○ → … → ✓
10
+ // — `opened` and `in_progress` deliberately share the `…` icon so the
11
+ // learner doesn't get confronted with a "you opened it but didn't engage"
12
+ // distinct symbol; the data distinction exists for analytics hooks only.
13
+ import { expect, test } from '@playwright/test';
14
+ import { readFileSync } from 'node:fs';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { dirname, resolve } from 'node:path';
17
+
18
+ const FIXTURE_DIR = dirname(fileURLToPath(import.meta.url));
19
+ const FIXTURE_BODY = readFileSync(resolve(FIXTURE_DIR, 'fixtures/curriculum.json'), 'utf-8');
20
+
21
+ test.beforeEach(async ({ page }) => {
22
+ await page.route('**/curriculum.json', (route) =>
23
+ route.fulfill({ contentType: 'application/json', body: FIXTURE_BODY })
24
+ );
25
+ });
26
+
27
+ test.describe('lesson lifecycle visual sequence', () => {
28
+ test('○ → … → ✓ across mount + completion', async ({ page }) => {
29
+ await page.goto('/');
30
+
31
+ // Pre-navigation: lesson 1 status icon is ○ (not_started).
32
+ await page.locator('aside nav button').first().click();
33
+ const firstLessonIcon = page.locator('aside nav ul ul button span').first();
34
+ await expect(firstLessonIcon).toHaveText('○');
35
+
36
+ // Navigate into the lesson — `opened` writes immediately and the
37
+ // progress store invalidates; the active row's icon switches to …
38
+ // (visually merged with in_progress per FR-P15).
39
+ await page.locator('aside nav ul ul button').first().click();
40
+ await expect(page).toHaveURL(/\/mod-01\/lesson-01$/);
41
+
42
+ const activeIcon = page.locator('aside nav ul ul button.bg-blue-100 span').first();
43
+ // Allow up to 1 s for the markLessonOpened + invalidateProgress
44
+ // round-trip; visually merged with in_progress.
45
+ await expect(activeIcon).toHaveText('…', { timeout: 1500 });
46
+
47
+ // Wait for the short-text-block sentinel to fire (1 s debounce
48
+ // + observer flush) → completion → ✓.
49
+ await expect(activeIcon).toHaveText('✓', { timeout: 5000 });
50
+ });
51
+ });
@@ -0,0 +1,98 @@
1
+ // Copyright 2026 Pointmatic
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // FR-P9 + FR-P11 regression coverage. Real lesson completion must:
5
+ // 1. Mark the lesson `in_progress` on first navigation.
6
+ // 2. Mark the lesson `complete` once every block fires its
7
+ // completion event — and reflect that with `✓` in the sidebar
8
+ // WITHOUT a page reload.
9
+ // 3. Increment the dashboard's "X of N completed" count.
10
+ // 4. Pre-fill the Next/Finish enabled state on revisit.
11
+ //
12
+ // We use the dedicated `e2e/fixtures/curriculum.json` (see e2e/README.md)
13
+ // so completion behavior is decoupled from whatever curriculum the
14
+ // smoke pipeline happens to build.
15
+ import { expect, test } from '@playwright/test';
16
+ import { readFileSync } from 'node:fs';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { dirname, resolve } from 'node:path';
19
+
20
+ const FIXTURE_DIR = dirname(fileURLToPath(import.meta.url));
21
+ const FIXTURE_BODY = readFileSync(resolve(FIXTURE_DIR, 'fixtures/curriculum.json'), 'utf-8');
22
+
23
+ test.beforeEach(async ({ page }) => {
24
+ await page.route('**/curriculum.json', (route) =>
25
+ route.fulfill({ contentType: 'application/json', body: FIXTURE_BODY })
26
+ );
27
+ });
28
+
29
+ test.describe('progress reactivity', () => {
30
+ test('navigating into a lesson marks it in_progress in the sidebar', async ({ page }) => {
31
+ await page.goto('/');
32
+
33
+ await page.locator('aside nav button').first().click();
34
+ await page.locator('aside nav ul ul button').first().click();
35
+ await expect(page).toHaveURL(/\/[^/]+\/[^/]+$/);
36
+
37
+ const activeIcon = page.locator('aside nav ul ul button.bg-blue-100 span').first();
38
+ await expect(activeIcon).not.toHaveText('○');
39
+ });
40
+
41
+ test('short text-block lesson completes (sidebar ✓ without reload)', async ({ page }) => {
42
+ await page.goto('/');
43
+ await page.locator('aside nav button').first().click();
44
+ // Click the *first* lesson in the first module — `mod-01/lesson-01`
45
+ // of the fixture is a short text block whose sentinel is in view.
46
+ await page.locator('aside nav ul ul button').first().click();
47
+ await expect(page).toHaveURL(/\/mod-01\/lesson-01$/);
48
+
49
+ const activeIcon = page.locator('aside nav ul ul button.bg-blue-100 span').first();
50
+ // Allow up to 5 s for the 1 s sentinel debounce + a generous test
51
+ // budget (CI scheduling jitter can stretch IntersectionObserver).
52
+ await expect(activeIcon).toHaveText('✓', { timeout: 5000 });
53
+ });
54
+
55
+ test('completing a lesson increments the dashboard total', async ({ page }) => {
56
+ await page.goto('/');
57
+ const dashLabel = page.getByText(/of \d+ lessons completed/);
58
+ await expect(dashLabel).toContainText(/^0 of \d+/);
59
+
60
+ await page.locator('aside nav button').first().click();
61
+ await page.locator('aside nav ul ul button').first().click();
62
+ await expect(page).toHaveURL(/\/mod-01\/lesson-01$/);
63
+ await page
64
+ .locator('aside nav ul ul button.bg-blue-100 span')
65
+ .first()
66
+ .waitFor({ state: 'visible' });
67
+
68
+ // Wait for completion, then go home to read the dashboard.
69
+ await expect(
70
+ page.locator('aside nav ul ul button.bg-blue-100 span').first()
71
+ ).toHaveText('✓', { timeout: 5000 });
72
+
73
+ await page.goto('/');
74
+ await expect(dashLabel).toContainText(/^1 of \d+/);
75
+ });
76
+
77
+ test('revisiting a complete lesson pre-fills Next as enabled', async ({ page }) => {
78
+ await page.goto('/');
79
+ await page.locator('aside nav button').first().click();
80
+ await page.locator('aside nav ul ul button').first().click();
81
+ await expect(page).toHaveURL(/\/mod-01\/lesson-01$/);
82
+
83
+ // Wait for first-time completion.
84
+ await expect(
85
+ page.locator('aside nav ul ul button.bg-blue-100 span').first()
86
+ ).toHaveText('✓', { timeout: 5000 });
87
+
88
+ // Navigate away then back.
89
+ await page.locator('aside nav ul ul button').nth(1).click();
90
+ await expect(page).toHaveURL(/\/mod-01\/lesson-02$/);
91
+ await page.locator('aside nav ul ul button').first().click();
92
+ await expect(page).toHaveURL(/\/mod-01\/lesson-01$/);
93
+
94
+ // Next/Finish is enabled immediately on revisit (FR-P2 / I.g revisit).
95
+ const nextBtn = page.getByRole('button', { name: /Next|Finish/ });
96
+ await expect(nextBtn).toBeEnabled({ timeout: 1000 });
97
+ });
98
+ });
@@ -0,0 +1,57 @@
1
+ // Copyright 2026 Pointmatic
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // FR-P13 regression coverage: a tall text block must not mark its
5
+ // lesson complete until the learner scrolls to the end-of-block
6
+ // sentinel. We exercise this directly:
7
+ //
8
+ // 1. Load the lesson; without scrolling, wait long enough that the
9
+ // 1 s debounce would have fired several times — assert no `✓`.
10
+ // 2. Scroll `<main>` to the bottom; assert `✓` appears within 2 s.
11
+ //
12
+ // Uses `e2e/fixtures/curriculum.json`'s `mod-01/lesson-02`, which has
13
+ // a 200vh spacer so the sentinel is below the fold on initial render.
14
+ import { expect, test } from '@playwright/test';
15
+ import { readFileSync } from 'node:fs';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { dirname, resolve } from 'node:path';
18
+
19
+ const FIXTURE_DIR = dirname(fileURLToPath(import.meta.url));
20
+ const FIXTURE_BODY = readFileSync(resolve(FIXTURE_DIR, 'fixtures/curriculum.json'), 'utf-8');
21
+
22
+ test.beforeEach(async ({ page }) => {
23
+ await page.route('**/curriculum.json', (route) =>
24
+ route.fulfill({ contentType: 'application/json', body: FIXTURE_BODY })
25
+ );
26
+ });
27
+
28
+ test.describe('TextBlock end-of-block sentinel (tall block)', () => {
29
+ test('tall lesson does not complete without scroll', async ({ page }) => {
30
+ await page.goto('/mod-01/lesson-02');
31
+ // Sidebar must auto-expand mod-01.
32
+ const activeIcon = page.locator('aside nav ul ul button.bg-blue-100 span').first();
33
+ await activeIcon.waitFor({ state: 'visible' });
34
+
35
+ // Wait well past the 1 s debounce; without scroll the sentinel
36
+ // stays below the fold and `textcomplete` must not fire.
37
+ await page.waitForTimeout(3000);
38
+ await expect(activeIcon).not.toHaveText('✓');
39
+ });
40
+
41
+ test('scrolling <main> to the bottom triggers completion', async ({ page }) => {
42
+ await page.goto('/mod-01/lesson-02');
43
+ const activeIcon = page.locator('aside nav ul ul button.bg-blue-100 span').first();
44
+ await activeIcon.waitFor({ state: 'visible' });
45
+
46
+ // Sanity: not yet complete.
47
+ await page.waitForTimeout(500);
48
+ await expect(activeIcon).not.toHaveText('✓');
49
+
50
+ // Scroll the inner `<main>` (the lesson content scroll container)
51
+ // to the bottom so the sentinel enters the viewport.
52
+ await page.locator('main').evaluate((el) => el.scrollTo(0, el.scrollHeight));
53
+
54
+ // 1 s debounce + observer flush; allow up to 3 s.
55
+ await expect(activeIcon).toHaveText('✓', { timeout: 3000 });
56
+ });
57
+ });
@@ -27,6 +27,8 @@
27
27
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
28
28
  "@tailwindcss/typography": "^0.5.16",
29
29
  "@tailwindcss/vite": "^4.0.0",
30
+ "@testing-library/jest-dom": "^6.6.0",
31
+ "@testing-library/svelte": "^5.2.0",
30
32
  "@types/sql.js": "^1.4.11",
31
33
  "jsdom": "^25.0.0",
32
34
  "prettier": "^3.0.0",
@@ -30,7 +30,10 @@
30
30
  function statusClass(lessonId: string): string {
31
31
  const s = progress[lessonId]?.status;
32
32
  if (s === 'complete') return 'text-green-600';
33
- if (s === 'in_progress') return 'text-blue-500';
33
+ // `opened` (Story I.p) shares the in_progress visual on purpose —
34
+ // learners shouldn't see "I opened it but didn't engage" as a
35
+ // distinct sidebar symbol; the distinction is data-only.
36
+ if (s === 'in_progress' || s === 'opened') return 'text-blue-500';
34
37
  return 'text-gray-400';
35
38
  }
36
39
 
@@ -0,0 +1,145 @@
1
+ <!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
2
+ <script lang="ts">
3
+ import {
4
+ getLessonProgress,
5
+ markLessonComplete,
6
+ markLessonInProgress,
7
+ markLessonOpened
8
+ } from '$lib/db/index.js';
9
+ import { curriculum } from '$lib/stores/curriculum.js';
10
+ import { invalidateProgress } from '$lib/stores/progress.js';
11
+ import type { Lesson, QuizScore } from '$lib/types/index.js';
12
+ import ContentBlock from './ContentBlock.svelte';
13
+ import Navigation from './Navigation.svelte';
14
+ import { contentBlockKey } from './lesson-view.helpers.js';
15
+ import { onMount } from 'svelte';
16
+
17
+ /**
18
+ * Lifecycle event payload (Story I.p / FR-P15). Each callback fires
19
+ * at most once per mount session and only when a meaningful state
20
+ * transition occurred:
21
+ * - `onlessonopen` — every mount.
22
+ * - `onlessonengage` — first block-completion event of the mount
23
+ * (suppressed on revisits to a `complete` lesson).
24
+ * - `onlessoncomplete` — when every content block has fired
25
+ * completion (suppressed on revisits to a `complete` lesson; for
26
+ * zero-block lessons fires immediately after `onlessonopen`).
27
+ * No internal subscribers exist today — these are forward-compatible
28
+ * hooks for future analytics / telemetry adapters.
29
+ */
30
+ export interface LessonLifecycleDetail {
31
+ moduleId: string;
32
+ lessonId: string;
33
+ }
34
+
35
+ interface Props {
36
+ lesson: Lesson;
37
+ moduleId: string;
38
+ onlessonopen?: (detail: LessonLifecycleDetail) => void;
39
+ onlessonengage?: (detail: LessonLifecycleDetail) => void;
40
+ onlessoncomplete?: (detail: LessonLifecycleDetail) => void;
41
+ }
42
+ let {
43
+ lesson,
44
+ moduleId,
45
+ onlessonopen,
46
+ onlessonengage,
47
+ onlessoncomplete
48
+ }: Props = $props();
49
+
50
+ let allBlocksComplete = $state(false);
51
+ let completedBlocks = $state(new Set<number>());
52
+ // Tracks whether we have already promoted this mount session to
53
+ // `in_progress` — first block-completion flips it; subsequent block
54
+ // completions re-use the existing row without reissuing the SQL.
55
+ let engaged = $state(false);
56
+
57
+ const lessonComplete = $derived(
58
+ allBlocksComplete || completedBlocks.size === lesson.content_blocks.length
59
+ );
60
+
61
+ function fireOpen() {
62
+ onlessonopen?.({ moduleId, lessonId: lesson.id });
63
+ }
64
+ function fireComplete() {
65
+ onlessoncomplete?.({ moduleId, lessonId: lesson.id });
66
+ }
67
+
68
+ onMount(async () => {
69
+ // Every mount records an open before any other state transition.
70
+ await markLessonOpened(moduleId, lesson.id);
71
+ fireOpen();
72
+
73
+ // Zero-block edge case — instant complete after open.
74
+ if (lesson.content_blocks.length === 0) {
75
+ allBlocksComplete = true;
76
+ await markLessonComplete(moduleId, lesson.id);
77
+ await invalidateProgress($curriculum);
78
+ fireComplete();
79
+ return;
80
+ }
81
+
82
+ // Revisit: pre-fill nav so Next/Finish is enabled immediately. No
83
+ // engage / complete events fire on revisit — no transition occurs.
84
+ const existing = await getLessonProgress(moduleId, lesson.id);
85
+ if (existing?.status === 'complete') {
86
+ allBlocksComplete = true;
87
+ engaged = true; // suppress redundant `markLessonInProgress` if a block fires
88
+ }
89
+ // First-engage promotion is now deferred to `handleBlockComplete`
90
+ // so a learner who opens a lesson but engages with no content is
91
+ // distinguishable from one genuinely partway through.
92
+ await invalidateProgress($curriculum);
93
+ });
94
+
95
+ async function handleBlockComplete(blockIndex: number) {
96
+ if (allBlocksComplete) return;
97
+ completedBlocks.add(blockIndex);
98
+ completedBlocks = new Set(completedBlocks);
99
+
100
+ // First engagement of the mount session: promote to in_progress
101
+ // and emit `lessonengage`.
102
+ if (!engaged) {
103
+ engaged = true;
104
+ await markLessonInProgress(moduleId, lesson.id);
105
+ onlessonengage?.({ moduleId, lessonId: lesson.id });
106
+ await invalidateProgress($curriculum);
107
+ }
108
+
109
+ if (completedBlocks.size === lesson.content_blocks.length) {
110
+ await markLessonComplete(moduleId, lesson.id);
111
+ // Refreshing the progress store is enough to drive the
112
+ // `unlock_module_on_complete` cascade: locking utilities in
113
+ // `$lib/utils/locking.ts` re-derive sibling-optional and
114
+ // next-module-unlocked state from the new `complete` status —
115
+ // no additional DB write or extra invalidation is required.
116
+ await invalidateProgress($curriculum);
117
+ fireComplete();
118
+ }
119
+ }
120
+
121
+ function handleQuizComplete(_score: QuizScore) {
122
+ // Score already persisted by QuizBlock; could trigger further logic here
123
+ }
124
+ </script>
125
+
126
+ <article class="mx-auto max-w-3xl space-y-8 py-6">
127
+ <header>
128
+ <h1 class="text-2xl font-bold text-gray-900">{lesson.title}</h1>
129
+ </header>
130
+
131
+ <div class="space-y-8">
132
+ {#each lesson.content_blocks as block, i (contentBlockKey(block, i))}
133
+ <section>
134
+ <ContentBlock
135
+ {block}
136
+ blockIndex={i}
137
+ onblockcomplete={handleBlockComplete}
138
+ onquizcomplete={handleQuizComplete}
139
+ />
140
+ </section>
141
+ {/each}
142
+ </div>
143
+
144
+ <Navigation disabled={!lessonComplete} />
145
+ </article>