learningfoundry 0.62.0__tar.gz → 0.62.2__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 (104) hide show
  1. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/CHANGELOG.md +20 -0
  2. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/PKG-INFO +1 -1
  3. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/pyproject.toml +1 -1
  4. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/__init__.py +1 -1
  5. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/schema_v1.py +28 -12
  6. learningfoundry-0.62.2/src/learningfoundry/sveltekit_template/src/lib/components/LockedLessonPlaceholder.svelte +39 -0
  7. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.test.ts +22 -0
  8. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +30 -3
  9. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +63 -0
  10. learningfoundry-0.62.2/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +90 -0
  11. learningfoundry-0.62.2/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/page.test.ts +146 -0
  12. learningfoundry-0.62.0/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -45
  13. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/.gitignore +0 -0
  14. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/LICENSE +0 -0
  15. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/README.md +0 -0
  16. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/docs/project-guide/README.md +0 -0
  17. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/__main__.py +0 -0
  18. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/asset_resolver.py +0 -0
  19. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/cli.py +0 -0
  20. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/config.py +0 -0
  21. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/exceptions.py +0 -0
  22. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/generator.py +0 -0
  23. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/integrations/__init__.py +0 -0
  24. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
  25. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
  26. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/integrations/protocols.py +0 -0
  27. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/integrations/quizazz.py +0 -0
  28. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/logging_config.py +0 -0
  29. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/parser.py +0 -0
  30. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/pipeline.py +0 -0
  31. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/py.typed +0 -0
  32. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/resolver.py +0 -0
  33. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/README.md +0 -0
  34. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/finish.spec.ts +0 -0
  35. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/fixtures/curriculum.json +0 -0
  36. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/global-teardown.ts +0 -0
  37. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/lifecycle.spec.ts +0 -0
  38. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/navigation.spec.ts +0 -0
  39. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +0 -0
  40. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/reset.spec.ts +0 -0
  41. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/text-block-bottom.spec.ts +0 -0
  42. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/e2e/video.spec.ts +0 -0
  43. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/package.json +0 -0
  44. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/playwright.config.ts +0 -0
  45. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
  46. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
  47. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
  48. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
  49. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  50. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.svelte +0 -0
  51. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.test.ts +0 -0
  52. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +0 -0
  53. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +0 -0
  54. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
  55. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/Navigation.svelte +0 -0
  56. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  57. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  58. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
  59. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.svelte +0 -0
  60. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.test.ts +0 -0
  61. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.observer.test.ts +0 -0
  62. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +0 -0
  63. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +0 -0
  64. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
  65. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +0 -0
  66. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  67. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +0 -0
  68. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.helpers.ts +0 -0
  69. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.test.ts +0 -0
  70. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/mount.test.ts +0 -0
  71. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.helpers.ts +0 -0
  72. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.test.ts +0 -0
  73. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
  74. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/db/database.test.ts +0 -0
  75. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
  76. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +0 -0
  77. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/db/progress.test.ts +0 -0
  78. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/db/progress.ts +0 -0
  79. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/db/user-id.test.ts +0 -0
  80. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/db/user-id.ts +0 -0
  81. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
  82. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
  83. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +0 -0
  84. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +0 -0
  85. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +0 -0
  86. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +0 -0
  87. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.ts +0 -0
  88. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
  89. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  90. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/utils/progress.test.ts +0 -0
  91. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/utils/progress.ts +0 -0
  92. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
  93. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +0 -0
  94. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
  95. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
  96. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/routes/layout.helpers.ts +0 -0
  97. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
  98. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
  99. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +0 -0
  100. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
  101. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
  102. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/test-results/.last-run.json +0 -0
  103. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
  104. {learningfoundry-0.62.0 → learningfoundry-0.62.2}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.62.2] - 2026-05-02
11
+
12
+ ### Fixed
13
+
14
+ - **Dashboard "Start module →" CTA didn't reflect locked state** (Story I.aa.3). [ProgressDashboard.svelte](src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte) rendered the same active-blue button for every non-complete module regardless of whether `locking.sequential` had locked it. Story I.aa.2 caught the click at the lesson route and rendered a placeholder, but the dashboard was still inviting the click. Same anti-pattern as I.aa.2: locking enforcement was in one place (sidebar) and missed at every other entry point. Fix: dashboard now derives `lockedModules` from the same `lockedModuleIds` helper the sidebar uses, and renders a `Locked` indicator (Lucide Lock icon + `text-gray-400` + `aria-disabled="true"`) instead of the action button when a module is locked. The module title also picks up the Lock icon for visual cohesion with the sidebar idiom.
15
+
16
+ ## [0.62.1] - 2026-05-02
17
+
18
+ ### Fixed
19
+
20
+ - **Locking config silently disabled when `sequential: true` was mis-placed in the YAML** (Story I.aa.2). Repro: a curriculum YAML with `sequential: true` written one indent level too high (directly under `curriculum:` instead of nested under `curriculum.locking:`) silently parsed with `locking.sequential = false`, so every module was freely expandable, every lesson freely openable, and no lock icon appeared in the sidebar. Pydantic's default `extra='ignore'` ate the unknown field without any warning. Compounded by [+page.svelte](src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte) having no locking guard at all — even with the schema fixed, typing or bookmarking a locked-lesson URL would have bypassed the sidebar's enforcement entirely.
21
+
22
+ ### Changed
23
+
24
+ - **Curriculum schema is now strict.** New `StrictModel` base class in [schema_v1.py](src/learningfoundry/schema_v1.py) sets `model_config = ConfigDict(extra='forbid')`. Every schema model (`CurriculumDef`, `Module`, `Lesson`, `LockingConfig`, `CurriculumV1`, `*Block` types, `AssessmentRef`) inherits from it. A misplaced or typo'd field anywhere in the YAML now produces a `ValidationError` with a JSON-pointer path to the offending field — e.g. `curriculum.sequential — Extra inputs are not permitted`. Breaking change for any curriculum YAML that contained benign extra fields; the project ships only the test fixtures and one user's curriculum, none of which use extras.
25
+
26
+ ### Added
27
+
28
+ - **Lesson-route locking failsafe** (Story I.aa.2). [+page.svelte](src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte) now derives `isLocked` from the same `isModuleLocked` / `isLessonLocked` helpers the sidebar uses, and renders a new [LockedLessonPlaceholder.svelte](src/learningfoundry/sveltekit_template/src/lib/components/LockedLessonPlaceholder.svelte) (lock icon + module/lesson titles + "Complete X to unlock this lesson" + Return-to-dashboard CTA) when the requested URL points at a locked lesson. The `navigateTo` side-effect is guarded by `!isLocked` so a locked-URL load doesn't write `currentPosition` and therefore doesn't highlight the gated module in the sidebar.
29
+
10
30
  ## [0.62.0] - 2026-05-02
11
31
 
12
32
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learningfoundry
3
- Version: 0.62.0
3
+ Version: 0.62.2
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.62.0"
7
+ version = "0.62.2"
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.62.0"
4
+ __version__ = "0.62.2"
@@ -5,7 +5,23 @@
5
5
  import re
6
6
  from typing import Annotated, Any, Literal, Self
7
7
 
8
- from pydantic import BaseModel, Field, field_validator, model_validator
8
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
9
+
10
+
11
+ class StrictModel(BaseModel):
12
+ """Base for every curriculum-schema model.
13
+
14
+ `extra='forbid'` makes Pydantic raise `ValidationError` on unknown
15
+ fields instead of silently dropping them — Story I.aa.2 root cause
16
+ was a `sequential: true` mis-placed at the curriculum top level
17
+ instead of nested under `locking:`. The unknown field was discarded
18
+ without a peep, the resolved curriculum.json shipped with
19
+ `locking.sequential = false`, and the entire module-locking feature
20
+ was silently disabled. Strict validation converts that class of typo
21
+ into a loud build-time error pointing at the offending field name.
22
+ """
23
+
24
+ model_config = ConfigDict(extra="forbid")
9
25
 
10
26
  _ID_RE = re.compile(r"^[a-z][a-z0-9]*(-[a-z0-9]+)*$")
11
27
  YOUTUBE_URL_RE = re.compile(
@@ -26,17 +42,17 @@ def _validate_id(v: str, field_name: str = "id") -> str:
26
42
  return v
27
43
 
28
44
 
29
- class AssessmentRef(BaseModel):
45
+ class AssessmentRef(StrictModel):
30
46
  source: str
31
47
  ref: str
32
48
 
33
49
 
34
- class TextBlock(BaseModel):
50
+ class TextBlock(StrictModel):
35
51
  type: Literal["text"]
36
52
  ref: str
37
53
 
38
54
 
39
- class VideoBlock(BaseModel):
55
+ class VideoBlock(StrictModel):
40
56
  """Video embed. ``provider`` selects the player; ``extensions`` carries
41
57
  player-specific options (chapters, transcript refs, etc.) without
42
58
  forcing a one-size-fits-all schema across providers.
@@ -59,20 +75,20 @@ class VideoBlock(BaseModel):
59
75
  return self
60
76
 
61
77
 
62
- class QuizBlock(BaseModel):
78
+ class QuizBlock(StrictModel):
63
79
  type: Literal["quiz"]
64
80
  source: str
65
81
  ref: str
66
82
  pass_threshold: float = Field(0.0, ge=0.0, le=1.0)
67
83
 
68
84
 
69
- class ExerciseBlock(BaseModel):
85
+ class ExerciseBlock(StrictModel):
70
86
  type: Literal["exercise"]
71
87
  source: str
72
88
  ref: str
73
89
 
74
90
 
75
- class VisualizationBlock(BaseModel):
91
+ class VisualizationBlock(StrictModel):
76
92
  type: Literal["visualization"]
77
93
  source: str
78
94
  ref: str
@@ -84,7 +100,7 @@ ContentBlock = Annotated[
84
100
  ]
85
101
 
86
102
 
87
- class Lesson(BaseModel):
103
+ class Lesson(StrictModel):
88
104
  id: str
89
105
  title: str
90
106
  unlock_module_on_complete: bool = False
@@ -96,7 +112,7 @@ class Lesson(BaseModel):
96
112
  return _validate_id(v, "lesson id")
97
113
 
98
114
 
99
- class Module(BaseModel):
115
+ class Module(StrictModel):
100
116
  id: str
101
117
  title: str
102
118
  description: str = ""
@@ -117,14 +133,14 @@ class Module(BaseModel):
117
133
  return self
118
134
 
119
135
 
120
- class LockingConfig(BaseModel):
136
+ class LockingConfig(StrictModel):
121
137
  """Curriculum-level content locking configuration."""
122
138
 
123
139
  sequential: bool = False
124
140
  lesson_sequential: bool = False
125
141
 
126
142
 
127
- class CurriculumDef(BaseModel):
143
+ class CurriculumDef(StrictModel):
128
144
  title: str
129
145
  description: str = ""
130
146
  locking: LockingConfig = Field(default_factory=LockingConfig)
@@ -158,6 +174,6 @@ class CurriculumDef(BaseModel):
158
174
  return self
159
175
 
160
176
 
161
- class CurriculumV1(BaseModel):
177
+ class CurriculumV1(StrictModel):
162
178
  version: str
163
179
  curriculum: CurriculumDef
@@ -0,0 +1,39 @@
1
+ <!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
2
+ <script lang="ts">
3
+ import Lock from 'lucide-svelte/icons/lock';
4
+
5
+ interface Props {
6
+ moduleTitle: string;
7
+ lessonTitle: string;
8
+ blockedBy: string | null;
9
+ }
10
+ let { moduleTitle, lessonTitle, blockedBy }: Props = $props();
11
+ </script>
12
+
13
+ <section
14
+ class="mx-auto flex max-w-xl flex-col items-center gap-4 px-6 py-16 text-center"
15
+ >
16
+ <div class="rounded-full bg-gray-100 p-4 text-gray-500">
17
+ <Lock size={32} aria-hidden="true" />
18
+ </div>
19
+ <h1 class="text-xl font-semibold text-gray-800">Lesson is locked</h1>
20
+ <p class="text-sm leading-relaxed text-gray-600">
21
+ <span class="font-medium">{moduleTitle}</span> &mdash;
22
+ <span>{lessonTitle}</span>
23
+ </p>
24
+ {#if blockedBy}
25
+ <p class="text-sm text-gray-600">
26
+ Complete <span class="font-medium">{blockedBy}</span> to unlock this lesson.
27
+ </p>
28
+ {:else}
29
+ <p class="text-sm text-gray-600">
30
+ This lesson is gated by the curriculum's locking rules.
31
+ </p>
32
+ {/if}
33
+ <a
34
+ href="/"
35
+ class="mt-2 inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
36
+ >
37
+ Return to dashboard
38
+ </a>
39
+ </section>
@@ -135,6 +135,28 @@ describe('ModuleList mount — locked vs unlocked modules', () => {
135
135
  expect(unlockedItem.querySelector('ul')).not.toBeNull();
136
136
  });
137
137
 
138
+ // Story I.aa.2 — visual regression guard. The lock icon + aria-disabled
139
+ // is covered above; this pins the rest of the locked-module styling
140
+ // (cursor-not-allowed on the button, gray-400 text) so a future
141
+ // refactor of the Tailwind classes can't silently regress the visual
142
+ // indicator that tells learners a module is gated.
143
+ it('locked module button carries cursor-not-allowed and gray-400 styling', () => {
144
+ const m1 = makeModule('mod-01', 'First');
145
+ const m2 = makeModule('mod-02', 'Locked');
146
+ const progress = {
147
+ 'mod-01': emptyProgress(m1),
148
+ 'mod-02': emptyProgress(m2)
149
+ };
150
+ const { container } = render(ModuleList, {
151
+ props: { modules: [m1, m2], progress, lockedModules: new Set(['mod-02']) }
152
+ });
153
+ const lockedBtn = (
154
+ container.querySelectorAll('nav > ul > li')[1] as HTMLElement
155
+ ).querySelector('button') as HTMLButtonElement;
156
+ expect(lockedBtn.className).toContain('cursor-not-allowed');
157
+ expect(lockedBtn.className).toContain('text-gray-400');
158
+ });
159
+
138
160
  // Story I.aa.1 — orthogonal coverage to Story I.y. The previous fix
139
161
  // handled "active lesson present, click course title": the position
140
162
  // transitioned from non-null → null and the auto-expand $effect's
@@ -2,9 +2,10 @@
2
2
  <script lang="ts">
3
3
  import { goto } from '$app/navigation';
4
4
  import type { Curriculum, Module, ModuleProgress, QuizScore } from '$lib/types/index.js';
5
- import { getOptionalLessons } from '$lib/utils/locking.js';
5
+ import { getOptionalLessons, lockedModuleIds } from '$lib/utils/locking.js';
6
6
  import { moduleStatus } from './progress-dashboard.helpers.js';
7
7
  import ProgressBar from './ProgressBar.svelte';
8
+ import Lock from 'lucide-svelte/icons/lock';
8
9
 
9
10
  interface Props {
10
11
  modules: Module[];
@@ -57,6 +58,14 @@
57
58
  const overallPct = $derived(
58
59
  totalLessons === 0 ? 0 : Math.round((totalComplete / totalLessons) * 100)
59
60
  );
61
+
62
+ // Story I.aa.3 — third entry-point closure for the locking model.
63
+ // The sidebar (Story I.i / I.j) and the lesson route (Story I.aa.2)
64
+ // already enforce locking. The dashboard CTA was the remaining
65
+ // invitation to a click that should not be available.
66
+ const lockedModules = $derived<Set<string>>(
67
+ curriculum ? lockedModuleIds(curriculum, progress) : new Set<string>()
68
+ );
60
69
  </script>
61
70
 
62
71
  <div class="space-y-6">
@@ -70,9 +79,19 @@
70
79
  <div class="space-y-4">
71
80
  {#each modules as mod (mod.id)}
72
81
  {@const stats = moduleStats(mod)}
82
+ {@const locked = lockedModules.has(mod.id)}
73
83
  <div class="rounded-lg border border-gray-200 bg-white p-4">
74
84
  <div class="mb-2 flex items-center justify-between">
75
- <h3 class="text-sm font-medium text-gray-800">{mod.title}</h3>
85
+ <h3
86
+ class="flex items-center gap-2 text-sm font-medium {locked
87
+ ? 'text-gray-400'
88
+ : 'text-gray-800'}"
89
+ >
90
+ {#if locked}
91
+ <Lock size={14} aria-hidden="true" />
92
+ {/if}
93
+ {mod.title}
94
+ </h3>
76
95
  <span class="text-xs text-gray-500">{stats.done}/{stats.total} lessons</span>
77
96
  </div>
78
97
  {#if mod.description}
@@ -93,7 +112,15 @@
93
112
  </p>
94
113
  {/if}
95
114
 
96
- {#if stats.status !== 'complete'}
115
+ {#if locked}
116
+ <p
117
+ class="mt-3 inline-flex items-center gap-1 text-xs font-medium text-gray-400"
118
+ aria-disabled="true"
119
+ >
120
+ <Lock size={12} aria-hidden="true" />
121
+ Locked
122
+ </p>
123
+ {:else if stats.status !== 'complete'}
97
124
  <button
98
125
  onclick={() => resumeFirst(mod)}
99
126
  class="mt-3 text-xs font-medium text-blue-600 hover:underline"
@@ -313,3 +313,66 @@ describe('ProgressDashboard mount — per-module action vs ✓ Complete', () =>
313
313
  expect(actionBtn!.textContent).toContain('Start module →');
314
314
  });
315
315
  });
316
+
317
+ // Story I.aa.3 — locked-module CTA. Story I.aa.2 added the lesson-route
318
+ // failsafe so a learner who *deep-links* a locked URL gets the placeholder.
319
+ // This story closes the third entry point: the dashboard's "Start module
320
+ // →" button must reflect locked state, not invite the click in the first
321
+ // place. Same fix shape as I.aa.2 — lock state is derived from the same
322
+ // `lockedModuleIds` helper the sidebar uses; the CTA renders a disabled
323
+ // "Locked" indicator instead of an action button.
324
+ describe('ProgressDashboard mount — locked module CTA (Story I.aa.3)', () => {
325
+ let ProgressDashboard: typeof import('./ProgressDashboard.svelte').default;
326
+
327
+ beforeEach(async () => {
328
+ ProgressDashboard = (await import('./ProgressDashboard.svelte')).default;
329
+ });
330
+
331
+ afterEach(() => {
332
+ vi.clearAllMocks();
333
+ });
334
+
335
+ it('locked sequential module renders a "Locked" indicator and no Start-module button; unlocked sibling still renders the button', async () => {
336
+ const m1 = makeModule('mod-01', 2);
337
+ const m2 = makeModule('mod-02', 2);
338
+ const curriculum = {
339
+ version: '1.0.0',
340
+ title: 'T',
341
+ description: '',
342
+ locking: { sequential: true, lesson_sequential: false },
343
+ modules: [
344
+ { ...m1, locked: null, pre_assessment: null, post_assessment: null },
345
+ { ...m2, locked: null, pre_assessment: null, post_assessment: null }
346
+ ]
347
+ };
348
+ const progress = {
349
+ 'mod-01': makeProgress(m1, 0), // not complete → m2 stays locked
350
+ 'mod-02': makeProgress(m2, 0)
351
+ };
352
+
353
+ const { render } = await import('@testing-library/svelte');
354
+ const { container } = render(ProgressDashboard, {
355
+ // `curriculum` is required for lock derivation.
356
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
357
+ props: { modules: [m1, m2], progress, curriculum: curriculum as any }
358
+ });
359
+
360
+ const cards = container.querySelectorAll('div.rounded-lg.border');
361
+ expect(cards.length).toBe(2);
362
+ const unlockedCard = cards[0] as HTMLElement;
363
+ const lockedCard = cards[1] as HTMLElement;
364
+
365
+ // Unlocked module 1 still has the action button.
366
+ expect(unlockedCard.querySelector('button')?.textContent ?? '').toContain(
367
+ 'Start module →'
368
+ );
369
+
370
+ // Locked module 2: no action button, "Locked" indicator visible,
371
+ // rendered with the same gray-400 styling as the sidebar's locked items.
372
+ expect(lockedCard.querySelector('button')).toBeNull();
373
+ expect(lockedCard.textContent ?? '').toMatch(/locked/i);
374
+ // Lucide Lock SVG carries the `lucide-lock` class — same idiom as
375
+ // the sidebar's locked-module rendering for visual consistency.
376
+ expect(lockedCard.querySelector('svg.lucide-lock')).not.toBeNull();
377
+ });
378
+ });
@@ -0,0 +1,90 @@
1
+ <!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
2
+ <script lang="ts">
3
+ import { page } from '$app/state';
4
+ import { curriculum, navigateTo } from '$lib/stores/curriculum.js';
5
+ import { progressStore } from '$lib/stores/progress.js';
6
+ import type { Lesson, Module } from '$lib/types/index.js';
7
+ import { isLessonLocked, isModuleLocked } from '$lib/utils/locking.js';
8
+ import LessonView from '$lib/components/LessonView.svelte';
9
+ import LockedLessonPlaceholder from '$lib/components/LockedLessonPlaceholder.svelte';
10
+ import { onMount } from 'svelte';
11
+
12
+ const moduleId = $derived(page.params.module);
13
+ const lessonId = $derived(page.params.lesson);
14
+
15
+ const currentModule = $derived<Module | null>(
16
+ $curriculum?.modules.find((m) => m.id === moduleId) ?? null
17
+ );
18
+ const currentLesson = $derived<Lesson | null>(
19
+ currentModule?.lessons.find((l) => l.id === lessonId) ?? null
20
+ );
21
+
22
+ // Story I.aa.2 — locking failsafe. The sidebar already filters
23
+ // locked-module clicks (Story I.i / I.j), but a learner who types or
24
+ // bookmarks a locked-lesson URL would otherwise bypass the model
25
+ // entirely. Compute lock state here from the same helpers the
26
+ // sidebar uses, render a placeholder if locked, and skip the
27
+ // `navigateTo` side-effect so the sidebar doesn't highlight a module
28
+ // the learner can't actually access.
29
+ const moduleIndex = $derived<number>(
30
+ $curriculum?.modules.findIndex((m) => m.id === moduleId) ?? -1
31
+ );
32
+ const lessonIndex = $derived<number>(
33
+ currentModule?.lessons.findIndex((l) => l.id === lessonId) ?? -1
34
+ );
35
+ const isLocked = $derived<boolean>(
36
+ !!$curriculum &&
37
+ !!currentModule &&
38
+ !!currentLesson &&
39
+ (isModuleLocked(moduleIndex, $curriculum, $progressStore) ||
40
+ isLessonLocked(currentModule.id, lessonIndex, $curriculum, $progressStore))
41
+ );
42
+ const blockedByTitle = $derived<string | null>(
43
+ (() => {
44
+ if (!isLocked || !$curriculum || !currentModule) return null;
45
+ // `lesson_sequential` blocks on the previous lesson within the same module.
46
+ if (lessonIndex > 0 && $curriculum.locking?.lesson_sequential) {
47
+ return currentModule.lessons[lessonIndex - 1]?.title ?? null;
48
+ }
49
+ // Otherwise the block is module-level: name the previous module.
50
+ if (moduleIndex > 0) return $curriculum.modules[moduleIndex - 1]?.title ?? null;
51
+ return null;
52
+ })()
53
+ );
54
+
55
+ // Sync URL params into the curriculum store position — but only when
56
+ // the lesson is *accessible*. Navigating to a locked URL must not
57
+ // highlight the locked module in the sidebar; that would be visually
58
+ // inconsistent with "you can't go here."
59
+ onMount(() => {
60
+ if (moduleId && lessonId && !isLocked) navigateTo(moduleId, lessonId);
61
+ });
62
+
63
+ $effect(() => {
64
+ if (moduleId && lessonId && !isLocked) navigateTo(moduleId, lessonId);
65
+ });
66
+ </script>
67
+
68
+ <svelte:head>
69
+ <title>{currentLesson?.title ?? 'Lesson'} — {$curriculum?.title ?? 'LearningFoundry'}</title>
70
+ </svelte:head>
71
+
72
+ {#if currentLesson && currentModule && isLocked}
73
+ <LockedLessonPlaceholder
74
+ moduleTitle={currentModule.title}
75
+ lessonTitle={currentLesson.title}
76
+ blockedBy={blockedByTitle}
77
+ />
78
+ {:else if currentLesson && currentModule}
79
+ {#key `${currentModule.id}/${currentLesson.id}`}
80
+ <LessonView lesson={currentLesson} moduleId={currentModule.id} />
81
+ {/key}
82
+ {:else if $curriculum}
83
+ <div class="flex h-full items-center justify-center">
84
+ <p class="text-gray-400">Lesson not found.</p>
85
+ </div>
86
+ {:else}
87
+ <div class="flex h-full items-center justify-center">
88
+ <p class="text-gray-400">Loading…</p>
89
+ </div>
90
+ {/if}
@@ -0,0 +1,146 @@
1
+ // Copyright 2026 Pointmatic
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // Story I.aa.2 — locking failsafe at the lesson route. The sidebar
5
+ // already filters locked modules from clicks (Story I.i / I.j), but a
6
+ // learner who types or bookmarks a locked-lesson URL would otherwise
7
+ // land on the full LessonView, bypassing the locking model entirely.
8
+ // The route must render a "locked" placeholder instead.
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { render } from '@testing-library/svelte';
11
+ import { writable } from 'svelte/store';
12
+ import type { Curriculum, ModuleProgress } from '$lib/types/index.js';
13
+
14
+ const { pageState } = vi.hoisted(() => ({
15
+ pageState: { params: { module: 'mod-02', lesson: 'lesson-01' } }
16
+ }));
17
+
18
+ vi.mock('$app/state', () => ({ page: pageState }));
19
+ vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
20
+
21
+ // Real writables so the page reactively reads the curriculum + progress.
22
+ const curriculumStore = writable<Curriculum | null>(null);
23
+ const progressStore = writable<Record<string, ModuleProgress>>({});
24
+
25
+ vi.mock('$lib/stores/curriculum.js', async () => {
26
+ const actual = await vi.importActual<
27
+ typeof import('$lib/stores/curriculum.js')
28
+ >('$lib/stores/curriculum.js');
29
+ return {
30
+ ...actual,
31
+ curriculum: curriculumStore,
32
+ navigateTo: vi.fn()
33
+ };
34
+ });
35
+
36
+ vi.mock('$lib/stores/progress.js', async () => {
37
+ const actual = await vi.importActual<
38
+ typeof import('$lib/stores/progress.js')
39
+ >('$lib/stores/progress.js');
40
+ return {
41
+ ...actual,
42
+ progressStore,
43
+ invalidateProgress: vi.fn()
44
+ };
45
+ });
46
+
47
+ // LessonView's onMount writes through the SQLite repo on lesson open,
48
+ // which would touch `localStorage` / IDB / the wasm fetch — none wired
49
+ // up under this test. Stub the repo to no-op so we can assert purely on
50
+ // the route's render decision.
51
+ vi.mock('$lib/db/index.js', () => ({
52
+ progressRepo: {
53
+ markLessonOpened: vi.fn().mockResolvedValue(undefined),
54
+ markLessonInProgress: vi.fn().mockResolvedValue(undefined),
55
+ markLessonComplete: vi.fn().mockResolvedValue(undefined),
56
+ recordQuizScore: vi.fn().mockResolvedValue(undefined),
57
+ recordExerciseStatus: vi.fn().mockResolvedValue(undefined),
58
+ getLessonProgress: vi.fn().mockResolvedValue(null),
59
+ listAllProgress: vi.fn().mockResolvedValue([])
60
+ },
61
+ database: { getDb: vi.fn(), persist: vi.fn() }
62
+ }));
63
+
64
+ function makeCurriculum(): Curriculum {
65
+ return {
66
+ version: '1.0.0',
67
+ title: 'T',
68
+ description: '',
69
+ locking: { sequential: true, lesson_sequential: false },
70
+ modules: [
71
+ {
72
+ id: 'mod-01',
73
+ title: 'M1',
74
+ description: '',
75
+ locked: null,
76
+ pre_assessment: null,
77
+ post_assessment: null,
78
+ lessons: [
79
+ {
80
+ id: 'lesson-01',
81
+ title: 'L1',
82
+ unlock_module_on_complete: false,
83
+ content_blocks: []
84
+ }
85
+ ]
86
+ },
87
+ {
88
+ id: 'mod-02',
89
+ title: 'M2',
90
+ description: '',
91
+ locked: null,
92
+ pre_assessment: null,
93
+ post_assessment: null,
94
+ lessons: [
95
+ {
96
+ id: 'lesson-01',
97
+ title: 'L1',
98
+ unlock_module_on_complete: false,
99
+ content_blocks: []
100
+ }
101
+ ]
102
+ }
103
+ ]
104
+ } as Curriculum;
105
+ }
106
+
107
+ describe('lesson route — locking failsafe (Story I.aa.2)', () => {
108
+ beforeEach(() => {
109
+ curriculumStore.set(null);
110
+ progressStore.set({});
111
+ pageState.params = { module: 'mod-02', lesson: 'lesson-01' };
112
+ });
113
+
114
+ afterEach(() => {
115
+ vi.clearAllMocks();
116
+ });
117
+
118
+ it('renders a Locked placeholder (not the LessonView) when navigating directly to a lesson in a sequentially-locked module', async () => {
119
+ curriculumStore.set(makeCurriculum());
120
+ progressStore.set({}); // Module 1 not complete → Module 2 locked.
121
+
122
+ const Page = (await import('./+page.svelte')).default;
123
+ const { container } = render(Page);
124
+
125
+ // LessonView mounts an <article> root; locked placeholder must not.
126
+ expect(container.querySelector('article')).toBeNull();
127
+ // Placeholder should announce the locked state.
128
+ expect(container.textContent).toMatch(/locked/i);
129
+ // And offer a return path to the dashboard.
130
+ const link = container.querySelector('a[href="/"]');
131
+ expect(link).not.toBeNull();
132
+ });
133
+
134
+ it('renders the LessonView when the requested lesson is in an unlocked module', async () => {
135
+ curriculumStore.set(makeCurriculum());
136
+ progressStore.set({});
137
+ pageState.params = { module: 'mod-01', lesson: 'lesson-01' };
138
+
139
+ const Page = (await import('./+page.svelte')).default;
140
+ const { container } = render(Page);
141
+
142
+ // Module 1 is the first sequential module → not locked.
143
+ expect(container.querySelector('article')).not.toBeNull();
144
+ expect(container.textContent ?? '').not.toMatch(/this lesson is locked/i);
145
+ });
146
+ });
@@ -1,45 +0,0 @@
1
- <!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
2
- <script lang="ts">
3
- import { page } from '$app/state';
4
- import { curriculum, navigateTo } from '$lib/stores/curriculum.js';
5
- import type { Lesson, Module } from '$lib/types/index.js';
6
- import LessonView from '$lib/components/LessonView.svelte';
7
- import { onMount } from 'svelte';
8
-
9
- const moduleId = $derived(page.params.module);
10
- const lessonId = $derived(page.params.lesson);
11
-
12
- const currentModule = $derived<Module | null>(
13
- $curriculum?.modules.find((m) => m.id === moduleId) ?? null
14
- );
15
- const currentLesson = $derived<Lesson | null>(
16
- currentModule?.lessons.find((l) => l.id === lessonId) ?? null
17
- );
18
-
19
- // Sync URL params into the curriculum store position
20
- onMount(() => {
21
- if (moduleId && lessonId) navigateTo(moduleId, lessonId);
22
- });
23
-
24
- $effect(() => {
25
- if (moduleId && lessonId) navigateTo(moduleId, lessonId);
26
- });
27
- </script>
28
-
29
- <svelte:head>
30
- <title>{currentLesson?.title ?? 'Lesson'} — {$curriculum?.title ?? 'LearningFoundry'}</title>
31
- </svelte:head>
32
-
33
- {#if currentLesson && currentModule}
34
- {#key `${currentModule.id}/${currentLesson.id}`}
35
- <LessonView lesson={currentLesson} moduleId={currentModule.id} />
36
- {/key}
37
- {:else if $curriculum}
38
- <div class="flex h-full items-center justify-center">
39
- <p class="text-gray-400">Lesson not found.</p>
40
- </div>
41
- {:else}
42
- <div class="flex h-full items-center justify-center">
43
- <p class="text-gray-400">Loading…</p>
44
- </div>
45
- {/if}