learningfoundry 0.45.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 (111) hide show
  1. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/CHANGELOG.md +17 -0
  2. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/PKG-INFO +1 -1
  3. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/pyproject.toml +1 -1
  4. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/__init__.py +1 -1
  5. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/e2e/navigation.spec.ts +30 -0
  6. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +24 -0
  7. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/e2e/video.spec.ts +27 -0
  8. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/package.json +2 -0
  9. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/playwright.config.ts +27 -0
  10. {learningfoundry-0.45.0 → learningfoundry-0.46.0/src/learningfoundry}/sveltekit_template/src/lib/components/LessonList.svelte +4 -2
  11. {learningfoundry-0.45.0 → learningfoundry-0.46.0/src/learningfoundry}/sveltekit_template/src/lib/components/LessonView.svelte +2 -1
  12. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +50 -1
  13. {learningfoundry-0.45.0 → learningfoundry-0.46.0/src/learningfoundry}/sveltekit_template/src/lib/components/Navigation.svelte +6 -8
  14. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +2 -2
  15. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +16 -10
  16. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +15 -0
  17. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/navigation.helpers.ts +33 -0
  18. learningfoundry-0.46.0/src/learningfoundry/sveltekit_template/src/lib/components/navigation.test.ts +43 -0
  19. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +11 -0
  20. {learningfoundry-0.45.0 → learningfoundry-0.46.0/src/learningfoundry}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +3 -1
  21. {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/LessonList.svelte +4 -2
  22. {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/LessonView.svelte +2 -1
  23. {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/Navigation.svelte +6 -8
  24. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/ProgressDashboard.svelte +2 -2
  25. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/VideoBlock.svelte +16 -10
  26. learningfoundry-0.46.0/sveltekit_template/src/lib/components/lesson-view.helpers.ts +63 -0
  27. learningfoundry-0.46.0/sveltekit_template/src/lib/components/navigation.helpers.ts +33 -0
  28. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/stores/curriculum.ts +10 -0
  29. {learningfoundry-0.45.0/src/learningfoundry → learningfoundry-0.46.0}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +3 -1
  30. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/.gitignore +0 -0
  31. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/LICENSE +0 -0
  32. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/README.md +0 -0
  33. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/docs/project-guide/README.md +0 -0
  34. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/__main__.py +0 -0
  35. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/asset_resolver.py +0 -0
  36. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/cli.py +0 -0
  37. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/config.py +0 -0
  38. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/exceptions.py +0 -0
  39. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/generator.py +0 -0
  40. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/__init__.py +0 -0
  41. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
  42. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
  43. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/protocols.py +0 -0
  44. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/integrations/quizazz.py +0 -0
  45. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/logging_config.py +0 -0
  46. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/parser.py +0 -0
  47. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/pipeline.py +0 -0
  48. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/py.typed +0 -0
  49. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/resolver.py +0 -0
  50. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/schema_v1.py +0 -0
  51. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
  52. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
  53. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
  54. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
  55. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  56. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
  57. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  58. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  59. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +0 -0
  60. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
  61. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +0 -0
  62. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +0 -0
  63. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +0 -0
  64. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  65. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.helpers.ts +0 -0
  66. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.test.ts +0 -0
  67. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
  68. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
  69. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +0 -0
  70. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/db/progress.ts +0 -0
  71. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
  72. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +0 -0
  73. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +0 -0
  74. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +0 -0
  75. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +0 -0
  76. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.ts +0 -0
  77. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
  78. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  79. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
  80. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +0 -0
  81. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
  82. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
  83. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
  84. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
  85. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +0 -0
  86. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
  87. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
  88. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
  89. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
  90. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/app.css +0 -0
  91. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/app.html +0 -0
  92. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
  93. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  94. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
  95. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  96. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  97. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
  98. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/TextBlock.svelte +0 -0
  99. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  100. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/module-list.helpers.ts +0 -0
  101. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
  102. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/db/database.ts +0 -0
  103. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/db/index.ts +0 -0
  104. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/db/progress.ts +0 -0
  105. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/stores/progress.ts +0 -0
  106. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/types/index.ts +0 -0
  107. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/utils/locking.ts +0 -0
  108. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  109. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
  110. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/routes/+layout.svelte +0 -0
  111. {learningfoundry-0.45.0 → learningfoundry-0.46.0}/sveltekit_template/src/routes/+page.svelte +0 -0
@@ -7,6 +7,23 @@ 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
+
10
27
  ## [0.45.0] - 2026-04-30
11
28
 
12
29
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learningfoundry
3
- Version: 0.45.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "learningfoundry"
7
- version = "0.45.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.45.0"
4
+ __version__ = "0.46.0"
@@ -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,8 +1,10 @@
1
1
  <!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
2
2
  <script lang="ts">
3
- import { currentPosition, navigateTo } from '$lib/stores/curriculum.js';
3
+ import { goto } from '$app/navigation';
4
+ import { currentPosition } from '$lib/stores/curriculum.js';
4
5
  import type { Lesson, LessonProgress } from '$lib/types/index.js';
5
6
  import { lessonStatusIcon, resolveLessonClick } from './module-list.helpers.js';
7
+ import { lessonHref } from './navigation.helpers.js';
6
8
 
7
9
  interface Props {
8
10
  moduleId: string;
@@ -34,7 +36,7 @@
34
36
 
35
37
  function handleClick(lessonId: string) {
36
38
  if (resolveLessonClick(lessonId, lockedLessons) === 'noop') return;
37
- navigateTo(moduleId, lessonId);
39
+ void goto(lessonHref(moduleId, lessonId));
38
40
  }
39
41
  </script>
40
42
 
@@ -6,6 +6,7 @@
6
6
  import type { Lesson, QuizScore } from '$lib/types/index.js';
7
7
  import ContentBlock from './ContentBlock.svelte';
8
8
  import Navigation from './Navigation.svelte';
9
+ import { contentBlockKey } from './lesson-view.helpers.js';
9
10
  import { onMount } from 'svelte';
10
11
 
11
12
  interface Props {
@@ -65,7 +66,7 @@
65
66
  </header>
66
67
 
67
68
  <div class="space-y-8">
68
- {#each lesson.content_blocks as block, i (i)}
69
+ {#each lesson.content_blocks as block, i (contentBlockKey(block, i))}
69
70
  <section>
70
71
  <ContentBlock
71
72
  {block}
@@ -1,7 +1,8 @@
1
1
  // Copyright 2026 Pointmatic
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { describe, expect, it } from 'vitest';
4
- import { createBlockTracker } from './lesson-view.helpers.js';
4
+ import { contentBlockKey, createBlockTracker } from './lesson-view.helpers.js';
5
+ import type { ContentBlock } from '$lib/types/index.js';
5
6
 
6
7
  describe('LessonView block tracking (via createBlockTracker)', () => {
7
8
  it('all blocks complete → returns true on final markBlockComplete', () => {
@@ -66,3 +67,51 @@ describe('LessonView block tracking (via createBlockTracker)', () => {
66
67
  expect(tracker.allComplete).toBe(true);
67
68
  });
68
69
  });
70
+
71
+ describe('contentBlockKey (FR-P10 stable identity)', () => {
72
+ it('uses ref when present', () => {
73
+ const block = {
74
+ type: 'text',
75
+ source: null,
76
+ ref: 'content/mod-01/lesson-01.md',
77
+ content: { markdown: '', path: '' }
78
+ } as ContentBlock;
79
+ expect(contentBlockKey(block, 0)).toBe('text:content/mod-01/lesson-01.md');
80
+ });
81
+
82
+ it('uses content.url for video blocks (no ref)', () => {
83
+ const block = {
84
+ type: 'video',
85
+ source: null,
86
+ ref: null,
87
+ content: { url: 'https://www.youtube.com/watch?v=abc' }
88
+ } as ContentBlock;
89
+ expect(contentBlockKey(block, 0)).toBe('video:https://www.youtube.com/watch?v=abc');
90
+ });
91
+
92
+ it('falls back to type + index when neither ref nor url is present', () => {
93
+ const block = {
94
+ type: 'visualization',
95
+ source: null,
96
+ ref: null,
97
+ content: {}
98
+ } as unknown as ContentBlock;
99
+ expect(contentBlockKey(block, 2)).toBe('visualization-2');
100
+ });
101
+
102
+ it('two video blocks with different URLs produce different keys', () => {
103
+ const a = {
104
+ type: 'video',
105
+ source: null,
106
+ ref: null,
107
+ content: { url: 'https://youtu.be/AAA' }
108
+ } as ContentBlock;
109
+ const b = {
110
+ type: 'video',
111
+ source: null,
112
+ ref: null,
113
+ content: { url: 'https://youtu.be/BBB' }
114
+ } as ContentBlock;
115
+ expect(contentBlockKey(a, 0)).not.toBe(contentBlockKey(b, 0));
116
+ });
117
+ });
@@ -2,7 +2,8 @@
2
2
  <script lang="ts">
3
3
  import { goto } from '$app/navigation';
4
4
  import { ChevronLeft, ChevronRight } from 'lucide-svelte';
5
- import { nextLesson, previousLesson, navigateTo } from '$lib/stores/curriculum.js';
5
+ import { nextLesson, previousLesson } from '$lib/stores/curriculum.js';
6
+ import { resolveGoNext, resolveGoPrev } from './navigation.helpers.js';
6
7
 
7
8
  interface Props {
8
9
  disabled?: boolean;
@@ -13,16 +14,13 @@
13
14
  const next = $derived($nextLesson);
14
15
 
15
16
  function goNext() {
16
- if (disabled) return;
17
- if (next) {
18
- navigateTo(next.moduleId, next.lessonId);
19
- } else {
20
- void goto('/');
21
- }
17
+ const action = resolveGoNext(disabled, next);
18
+ if (action.kind === 'goto') void goto(action.url);
22
19
  }
23
20
 
24
21
  function goPrev() {
25
- if (prev) navigateTo(prev.moduleId, prev.lessonId);
22
+ const action = resolveGoPrev(prev);
23
+ if (action.kind === 'goto') void goto(action.url);
26
24
  }
27
25
  </script>
28
26
 
@@ -1,6 +1,6 @@
1
1
  <!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
2
2
  <script lang="ts">
3
- import { navigateTo } from '$lib/stores/curriculum.js';
3
+ import { goto } from '$app/navigation';
4
4
  import type { Curriculum, Module, ModuleProgress, QuizScore } from '$lib/types/index.js';
5
5
  import { getOptionalLessons, isModuleComplete } from '$lib/utils/locking.js';
6
6
  import ProgressBar from './ProgressBar.svelte';
@@ -45,7 +45,7 @@
45
45
  (l) => !optional.has(l.id) && mp?.lessons[l.id]?.status !== 'complete'
46
46
  );
47
47
  const target = firstIncomplete ?? mod.lessons[0];
48
- if (target) navigateTo(mod.id, target.id);
48
+ if (target) void goto(`/${mod.id}/${target.id}`);
49
49
  }
50
50
 
51
51
  const totalLessons = $derived(modules.reduce((n, m) => n + m.lessons.length, 0));
@@ -1,7 +1,6 @@
1
1
  <!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
2
2
  <script lang="ts">
3
3
  import type { VideoContent } from '$lib/types/index.js';
4
- import { onMount } from 'svelte';
5
4
 
6
5
  interface Props {
7
6
  content: VideoContent;
@@ -51,11 +50,21 @@
51
50
  };
52
51
  }
53
52
 
54
- onMount(() => {
53
+ // `$effect` re-runs whenever the watched URL changes, so when a parent
54
+ // reuses this component instance across lessons (e.g. two consecutive
55
+ // lessons each with a video block) we tear down the previous player
56
+ // and observer and create a fresh one for the new `videoId`. The
57
+ // {#key} wrapper at the lesson route plus the stable block key in
58
+ // `LessonView` already force a re-mount in normal flows; this effect
59
+ // is the belt-and-suspenders fallback if any caller skips both keys.
60
+ $effect(() => {
61
+ const url = content.url; // dependency
55
62
  if (provider !== 'youtube' || !onvideocomplete) return;
56
- const videoId = extractYouTubeId(content.url);
63
+ const videoId = extractYouTubeId(url);
57
64
  if (!videoId) return;
58
65
 
66
+ fired = false;
67
+
59
68
  let cleanup: (() => void) | undefined;
60
69
  /* eslint-disable @typescript-eslint/no-explicit-any */
61
70
  let player: any;
@@ -80,6 +89,8 @@
80
89
  }
81
90
  }
82
91
 
92
+ let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
93
+
83
94
  if ((window as any).YT?.Player) {
84
95
  createPlayer();
85
96
  } else {
@@ -95,21 +106,16 @@
95
106
  createPlayer();
96
107
  };
97
108
 
98
- const fallbackTimer = setTimeout(() => {
109
+ fallbackTimer = setTimeout(() => {
99
110
  if (!(window as any).YT?.Player && !fired) {
100
111
  cleanup = setupViewportFallback();
101
112
  }
102
113
  }, 5000);
103
-
104
- return () => {
105
- clearTimeout(fallbackTimer);
106
- player?.destroy?.();
107
- cleanup?.();
108
- };
109
114
  }
110
115
  /* eslint-enable @typescript-eslint/no-explicit-any */
111
116
 
112
117
  return () => {
118
+ if (fallbackTimer !== undefined) clearTimeout(fallbackTimer);
113
119
  player?.destroy?.();
114
120
  cleanup?.();
115
121
  };
@@ -6,6 +6,21 @@
6
6
  * unit-tested without mounting the Svelte component.
7
7
  */
8
8
 
9
+ import type { ContentBlock } from '$lib/types/index.js';
10
+
11
+ /**
12
+ * Stable identity key for a content block. Used as the `{#each}` key so
13
+ * child components (notably `VideoBlock`, which holds an iframe player)
14
+ * tear down and re-mount when the underlying content changes rather
15
+ * than being reused across lessons with stale state.
16
+ */
17
+ export function contentBlockKey(block: ContentBlock, index: number): string {
18
+ if (block.ref) return `${block.type}:${block.ref}`;
19
+ const url = (block.content as { url?: unknown })?.url;
20
+ if (typeof url === 'string') return `${block.type}:${url}`;
21
+ return `${block.type}-${index}`;
22
+ }
23
+
9
24
  export interface BlockTracker {
10
25
  /** Record a block as complete. Returns true if this was the final block. */
11
26
  markBlockComplete(blockIndex: number): boolean;
@@ -0,0 +1,33 @@
1
+ // Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Pure resolver for the Next/Finish and Previous button actions.
4
+ *
5
+ * Extracted so the routing decision can be unit-tested without mounting
6
+ * `Navigation.svelte` and stubbing all of its store dependencies. The
7
+ * component just calls these helpers and dispatches the resulting `goto`
8
+ * call to `$app/navigation`.
9
+ */
10
+ import type { NavPosition } from '$lib/stores/curriculum.js';
11
+
12
+ export type NavAction = { kind: 'noop' } | { kind: 'goto'; url: string };
13
+
14
+ /** Resolve the action for the Next/Finish button click. */
15
+ export function resolveGoNext(
16
+ disabled: boolean,
17
+ next: NavPosition | null
18
+ ): NavAction {
19
+ if (disabled) return { kind: 'noop' };
20
+ if (next) return { kind: 'goto', url: `/${next.moduleId}/${next.lessonId}` };
21
+ return { kind: 'goto', url: '/' };
22
+ }
23
+
24
+ /** Resolve the action for the Previous button click. */
25
+ export function resolveGoPrev(prev: NavPosition | null): NavAction {
26
+ if (!prev) return { kind: 'noop' };
27
+ return { kind: 'goto', url: `/${prev.moduleId}/${prev.lessonId}` };
28
+ }
29
+
30
+ /** URL path for an in-app lesson link. */
31
+ export function lessonHref(moduleId: string, lessonId: string): string {
32
+ return `/${moduleId}/${lessonId}`;
33
+ }
@@ -0,0 +1,43 @@
1
+ // Copyright 2026 Pointmatic
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { describe, expect, it } from 'vitest';
4
+ import { lessonHref, resolveGoNext, resolveGoPrev } from './navigation.helpers.js';
5
+
6
+ describe('resolveGoNext (Next/Finish button)', () => {
7
+ it('routes to /{module}/{lesson} when there is a next lesson', () => {
8
+ const action = resolveGoNext(false, { moduleId: 'mod-01', lessonId: 'lesson-02' });
9
+ expect(action).toEqual({ kind: 'goto', url: '/mod-01/lesson-02' });
10
+ });
11
+
12
+ it('routes to / when next is null (Finish on last lesson)', () => {
13
+ const action = resolveGoNext(false, null);
14
+ expect(action).toEqual({ kind: 'goto', url: '/' });
15
+ });
16
+
17
+ it('disabled state is a no-op even with a next lesson', () => {
18
+ const action = resolveGoNext(true, { moduleId: 'mod-01', lessonId: 'lesson-02' });
19
+ expect(action).toEqual({ kind: 'noop' });
20
+ });
21
+
22
+ it('disabled state is a no-op when next is null', () => {
23
+ const action = resolveGoNext(true, null);
24
+ expect(action).toEqual({ kind: 'noop' });
25
+ });
26
+ });
27
+
28
+ describe('resolveGoPrev (Previous button)', () => {
29
+ it('routes to /{module}/{lesson} when there is a previous lesson', () => {
30
+ const action = resolveGoPrev({ moduleId: 'mod-01', lessonId: 'lesson-01' });
31
+ expect(action).toEqual({ kind: 'goto', url: '/mod-01/lesson-01' });
32
+ });
33
+
34
+ it('no-op when prev is null (first lesson)', () => {
35
+ expect(resolveGoPrev(null)).toEqual({ kind: 'noop' });
36
+ });
37
+ });
38
+
39
+ describe('lessonHref', () => {
40
+ it('formats /{module}/{lesson}', () => {
41
+ expect(lessonHref('mod-01', 'lesson-01')).toBe('/mod-01/lesson-01');
42
+ });
43
+ });
@@ -86,6 +86,17 @@ export const nextLesson = derived(
86
86
  // Navigation helpers
87
87
  // ---------------------------------------------------------------------------
88
88
 
89
+ /**
90
+ * **Internal route-sync only — UI code must use `goto` directly.**
91
+ *
92
+ * Sets `currentPosition` and routes to `/${moduleId}/${lessonId}`. Used by the
93
+ * dynamic lesson route's URL→store `$effect` and by the legacy
94
+ * `navigateNext`/`navigatePrev` helpers; do **not** call from sidebars,
95
+ * dashboards, or navigation buttons. UI code should import `goto` from
96
+ * `$app/navigation` and build the path explicitly so SvelteKit's lifecycle
97
+ * (page params, scroll restoration, lesson-route `{#key}` re-mount) fires
98
+ * predictably for every user-initiated navigation.
99
+ */
89
100
  export function navigateTo(moduleId: string, lessonId: string): void {
90
101
  currentPosition.set({ moduleId, lessonId });
91
102
  void goto(`/${moduleId}/${lessonId}`);
@@ -31,7 +31,9 @@
31
31
  </svelte:head>
32
32
 
33
33
  {#if currentLesson && currentModule}
34
- <LessonView lesson={currentLesson} moduleId={currentModule.id} />
34
+ {#key `${currentModule.id}/${currentLesson.id}`}
35
+ <LessonView lesson={currentLesson} moduleId={currentModule.id} />
36
+ {/key}
35
37
  {:else if $curriculum}
36
38
  <div class="flex h-full items-center justify-center">
37
39
  <p class="text-gray-400">Lesson not found.</p>
@@ -1,8 +1,10 @@
1
1
  <!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
2
2
  <script lang="ts">
3
- import { currentPosition, navigateTo } from '$lib/stores/curriculum.js';
3
+ import { goto } from '$app/navigation';
4
+ import { currentPosition } from '$lib/stores/curriculum.js';
4
5
  import type { Lesson, LessonProgress } from '$lib/types/index.js';
5
6
  import { lessonStatusIcon, resolveLessonClick } from './module-list.helpers.js';
7
+ import { lessonHref } from './navigation.helpers.js';
6
8
 
7
9
  interface Props {
8
10
  moduleId: string;
@@ -34,7 +36,7 @@
34
36
 
35
37
  function handleClick(lessonId: string) {
36
38
  if (resolveLessonClick(lessonId, lockedLessons) === 'noop') return;
37
- navigateTo(moduleId, lessonId);
39
+ void goto(lessonHref(moduleId, lessonId));
38
40
  }
39
41
  </script>
40
42
 
@@ -6,6 +6,7 @@
6
6
  import type { Lesson, QuizScore } from '$lib/types/index.js';
7
7
  import ContentBlock from './ContentBlock.svelte';
8
8
  import Navigation from './Navigation.svelte';
9
+ import { contentBlockKey } from './lesson-view.helpers.js';
9
10
  import { onMount } from 'svelte';
10
11
 
11
12
  interface Props {
@@ -65,7 +66,7 @@
65
66
  </header>
66
67
 
67
68
  <div class="space-y-8">
68
- {#each lesson.content_blocks as block, i (i)}
69
+ {#each lesson.content_blocks as block, i (contentBlockKey(block, i))}
69
70
  <section>
70
71
  <ContentBlock
71
72
  {block}
@@ -2,7 +2,8 @@
2
2
  <script lang="ts">
3
3
  import { goto } from '$app/navigation';
4
4
  import { ChevronLeft, ChevronRight } from 'lucide-svelte';
5
- import { nextLesson, previousLesson, navigateTo } from '$lib/stores/curriculum.js';
5
+ import { nextLesson, previousLesson } from '$lib/stores/curriculum.js';
6
+ import { resolveGoNext, resolveGoPrev } from './navigation.helpers.js';
6
7
 
7
8
  interface Props {
8
9
  disabled?: boolean;
@@ -13,16 +14,13 @@
13
14
  const next = $derived($nextLesson);
14
15
 
15
16
  function goNext() {
16
- if (disabled) return;
17
- if (next) {
18
- navigateTo(next.moduleId, next.lessonId);
19
- } else {
20
- void goto('/');
21
- }
17
+ const action = resolveGoNext(disabled, next);
18
+ if (action.kind === 'goto') void goto(action.url);
22
19
  }
23
20
 
24
21
  function goPrev() {
25
- if (prev) navigateTo(prev.moduleId, prev.lessonId);
22
+ const action = resolveGoPrev(prev);
23
+ if (action.kind === 'goto') void goto(action.url);
26
24
  }
27
25
  </script>
28
26
 
@@ -1,6 +1,6 @@
1
1
  <!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
2
2
  <script lang="ts">
3
- import { navigateTo } from '$lib/stores/curriculum.js';
3
+ import { goto } from '$app/navigation';
4
4
  import type { Curriculum, Module, ModuleProgress, QuizScore } from '$lib/types/index.js';
5
5
  import { getOptionalLessons, isModuleComplete } from '$lib/utils/locking.js';
6
6
  import ProgressBar from './ProgressBar.svelte';
@@ -45,7 +45,7 @@
45
45
  (l) => !optional.has(l.id) && mp?.lessons[l.id]?.status !== 'complete'
46
46
  );
47
47
  const target = firstIncomplete ?? mod.lessons[0];
48
- if (target) navigateTo(mod.id, target.id);
48
+ if (target) void goto(`/${mod.id}/${target.id}`);
49
49
  }
50
50
 
51
51
  const totalLessons = $derived(modules.reduce((n, m) => n + m.lessons.length, 0));
@@ -1,7 +1,6 @@
1
1
  <!-- Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0 -->
2
2
  <script lang="ts">
3
3
  import type { VideoContent } from '$lib/types/index.js';
4
- import { onMount } from 'svelte';
5
4
 
6
5
  interface Props {
7
6
  content: VideoContent;
@@ -51,11 +50,21 @@
51
50
  };
52
51
  }
53
52
 
54
- onMount(() => {
53
+ // `$effect` re-runs whenever the watched URL changes, so when a parent
54
+ // reuses this component instance across lessons (e.g. two consecutive
55
+ // lessons each with a video block) we tear down the previous player
56
+ // and observer and create a fresh one for the new `videoId`. The
57
+ // {#key} wrapper at the lesson route plus the stable block key in
58
+ // `LessonView` already force a re-mount in normal flows; this effect
59
+ // is the belt-and-suspenders fallback if any caller skips both keys.
60
+ $effect(() => {
61
+ const url = content.url; // dependency
55
62
  if (provider !== 'youtube' || !onvideocomplete) return;
56
- const videoId = extractYouTubeId(content.url);
63
+ const videoId = extractYouTubeId(url);
57
64
  if (!videoId) return;
58
65
 
66
+ fired = false;
67
+
59
68
  let cleanup: (() => void) | undefined;
60
69
  /* eslint-disable @typescript-eslint/no-explicit-any */
61
70
  let player: any;
@@ -80,6 +89,8 @@
80
89
  }
81
90
  }
82
91
 
92
+ let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
93
+
83
94
  if ((window as any).YT?.Player) {
84
95
  createPlayer();
85
96
  } else {
@@ -95,21 +106,16 @@
95
106
  createPlayer();
96
107
  };
97
108
 
98
- const fallbackTimer = setTimeout(() => {
109
+ fallbackTimer = setTimeout(() => {
99
110
  if (!(window as any).YT?.Player && !fired) {
100
111
  cleanup = setupViewportFallback();
101
112
  }
102
113
  }, 5000);
103
-
104
- return () => {
105
- clearTimeout(fallbackTimer);
106
- player?.destroy?.();
107
- cleanup?.();
108
- };
109
114
  }
110
115
  /* eslint-enable @typescript-eslint/no-explicit-any */
111
116
 
112
117
  return () => {
118
+ if (fallbackTimer !== undefined) clearTimeout(fallbackTimer);
113
119
  player?.destroy?.();
114
120
  cleanup?.();
115
121
  };
@@ -0,0 +1,63 @@
1
+ // Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * LessonView block-tracking helpers.
4
+ *
5
+ * Extracted from `LessonView.svelte` so the completion logic can be
6
+ * unit-tested without mounting the Svelte component.
7
+ */
8
+
9
+ import type { ContentBlock } from '$lib/types/index.js';
10
+
11
+ /**
12
+ * Stable identity key for a content block. Used as the `{#each}` key so
13
+ * child components (notably `VideoBlock`, which holds an iframe player)
14
+ * tear down and re-mount when the underlying content changes rather
15
+ * than being reused across lessons with stale state.
16
+ */
17
+ export function contentBlockKey(block: ContentBlock, index: number): string {
18
+ if (block.ref) return `${block.type}:${block.ref}`;
19
+ const url = (block.content as { url?: unknown })?.url;
20
+ if (typeof url === 'string') return `${block.type}:${url}`;
21
+ return `${block.type}-${index}`;
22
+ }
23
+
24
+ export interface BlockTracker {
25
+ /** Record a block as complete. Returns true if this was the final block. */
26
+ markBlockComplete(blockIndex: number): boolean;
27
+ /** Number of completed blocks so far. */
28
+ readonly completedCount: number;
29
+ /** Whether all blocks are complete. */
30
+ readonly allComplete: boolean;
31
+ }
32
+
33
+ /**
34
+ * Create a block tracker for a lesson with `totalBlocks` content blocks.
35
+ *
36
+ * If `preComplete` is true, the lesson was already marked complete in the DB
37
+ * on a previous visit — all blocks are considered done from the start.
38
+ *
39
+ * Zero-block lessons are immediately complete.
40
+ */
41
+ export function createBlockTracker(
42
+ totalBlocks: number,
43
+ preComplete: boolean = false
44
+ ): BlockTracker {
45
+ const completed = new Set<number>();
46
+ const immediate = totalBlocks === 0 || preComplete;
47
+
48
+ return {
49
+ markBlockComplete(blockIndex: number): boolean {
50
+ if (immediate) return true;
51
+ completed.add(blockIndex);
52
+ return completed.size === totalBlocks;
53
+ },
54
+
55
+ get completedCount(): number {
56
+ return immediate ? totalBlocks : completed.size;
57
+ },
58
+
59
+ get allComplete(): boolean {
60
+ return immediate || completed.size === totalBlocks;
61
+ }
62
+ };
63
+ }
@@ -0,0 +1,33 @@
1
+ // Copyright 2026 Pointmatic — SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Pure resolver for the Next/Finish and Previous button actions.
4
+ *
5
+ * Extracted so the routing decision can be unit-tested without mounting
6
+ * `Navigation.svelte` and stubbing all of its store dependencies. The
7
+ * component just calls these helpers and dispatches the resulting `goto`
8
+ * call to `$app/navigation`.
9
+ */
10
+ import type { NavPosition } from '$lib/stores/curriculum.js';
11
+
12
+ export type NavAction = { kind: 'noop' } | { kind: 'goto'; url: string };
13
+
14
+ /** Resolve the action for the Next/Finish button click. */
15
+ export function resolveGoNext(
16
+ disabled: boolean,
17
+ next: NavPosition | null
18
+ ): NavAction {
19
+ if (disabled) return { kind: 'noop' };
20
+ if (next) return { kind: 'goto', url: `/${next.moduleId}/${next.lessonId}` };
21
+ return { kind: 'goto', url: '/' };
22
+ }
23
+
24
+ /** Resolve the action for the Previous button click. */
25
+ export function resolveGoPrev(prev: NavPosition | null): NavAction {
26
+ if (!prev) return { kind: 'noop' };
27
+ return { kind: 'goto', url: `/${prev.moduleId}/${prev.lessonId}` };
28
+ }
29
+
30
+ /** URL path for an in-app lesson link. */
31
+ export function lessonHref(moduleId: string, lessonId: string): string {
32
+ return `/${moduleId}/${lessonId}`;
33
+ }
@@ -85,6 +85,16 @@ export const nextLesson = derived(
85
85
  // Navigation helpers
86
86
  // ---------------------------------------------------------------------------
87
87
 
88
+ /**
89
+ * **Internal route-sync only — UI code must use `goto` directly.**
90
+ *
91
+ * Sets `currentPosition`. Used by the dynamic lesson route's URL→store
92
+ * `$effect`; do **not** call from sidebars, dashboards, or navigation
93
+ * buttons. UI code should import `goto` from `$app/navigation` and build
94
+ * the path explicitly so SvelteKit's lifecycle (page params, scroll
95
+ * restoration, lesson-route `{#key}` re-mount) fires predictably for
96
+ * every user-initiated navigation.
97
+ */
88
98
  export function navigateTo(moduleId: string, lessonId: string): void {
89
99
  currentPosition.set({ moduleId, lessonId });
90
100
  }
@@ -31,7 +31,9 @@
31
31
  </svelte:head>
32
32
 
33
33
  {#if currentLesson && currentModule}
34
- <LessonView lesson={currentLesson} moduleId={currentModule.id} />
34
+ {#key `${currentModule.id}/${currentLesson.id}`}
35
+ <LessonView lesson={currentLesson} moduleId={currentModule.id} />
36
+ {/key}
35
37
  {:else if $curriculum}
36
38
  <div class="flex h-full items-center justify-center">
37
39
  <p class="text-gray-400">Lesson not found.</p>