coursecode 0.1.0

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 (362) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -0
  3. package/THIRD_PARTY_NOTICES.md +22 -0
  4. package/bin/cli.js +331 -0
  5. package/framework/assets/logo-coursecode-black.svg +14 -0
  6. package/framework/assets/logo-coursecode-white.svg +14 -0
  7. package/framework/assets/logo-coursecode.svg +14 -0
  8. package/framework/css/01-base.css +160 -0
  9. package/framework/css/02-layout.css +499 -0
  10. package/framework/css/accessibility.css +834 -0
  11. package/framework/css/components/accordions.css +710 -0
  12. package/framework/css/components/assessments.css +520 -0
  13. package/framework/css/components/audio-player.css +570 -0
  14. package/framework/css/components/badges.css +80 -0
  15. package/framework/css/components/breadcrumbs.css +87 -0
  16. package/framework/css/components/buttons.css +707 -0
  17. package/framework/css/components/callouts.css +1280 -0
  18. package/framework/css/components/cards.css +475 -0
  19. package/framework/css/components/carousel.css +193 -0
  20. package/framework/css/components/checkbox-group.css +123 -0
  21. package/framework/css/components/checklist.css +203 -0
  22. package/framework/css/components/collapse.css +96 -0
  23. package/framework/css/components/comparison.css +33 -0
  24. package/framework/css/components/content-image.css +36 -0
  25. package/framework/css/components/document-gallery.css +425 -0
  26. package/framework/css/components/dropdown.css +115 -0
  27. package/framework/css/components/embed-frame.css +142 -0
  28. package/framework/css/components/engagement.css +412 -0
  29. package/framework/css/components/features.css +35 -0
  30. package/framework/css/components/flip-cards.css +253 -0
  31. package/framework/css/components/footer.css +353 -0
  32. package/framework/css/components/forms.css +294 -0
  33. package/framework/css/components/hero.css +216 -0
  34. package/framework/css/components/images.css +528 -0
  35. package/framework/css/components/interactive-timeline.css +274 -0
  36. package/framework/css/components/intro-cards.css +30 -0
  37. package/framework/css/components/lightbox.css +666 -0
  38. package/framework/css/components/loading.css +65 -0
  39. package/framework/css/components/modals.css +235 -0
  40. package/framework/css/components/notifications.css +107 -0
  41. package/framework/css/components/quote.css +150 -0
  42. package/framework/css/components/sidebar.css +684 -0
  43. package/framework/css/components/slide-header.css +52 -0
  44. package/framework/css/components/spinner.css +62 -0
  45. package/framework/css/components/stats.css +44 -0
  46. package/framework/css/components/steps.css +232 -0
  47. package/framework/css/components/tables.css +90 -0
  48. package/framework/css/components/tabs.css +347 -0
  49. package/framework/css/components/timeline.css +154 -0
  50. package/framework/css/components/toggle.css +95 -0
  51. package/framework/css/components/tooltip.css +226 -0
  52. package/framework/css/components/video-player.css +438 -0
  53. package/framework/css/design-tokens.css +707 -0
  54. package/framework/css/framework.css +86 -0
  55. package/framework/css/interactions/accessibility.css +75 -0
  56. package/framework/css/interactions/base.css +92 -0
  57. package/framework/css/interactions/drag-drop.css +295 -0
  58. package/framework/css/interactions/fill-in-the-blank.css +236 -0
  59. package/framework/css/interactions/hotspots.css +69 -0
  60. package/framework/css/interactions/index.css +45 -0
  61. package/framework/css/interactions/interactive-image.css +359 -0
  62. package/framework/css/interactions/likert.css +126 -0
  63. package/framework/css/interactions/matching.css +354 -0
  64. package/framework/css/interactions/numeric-input.css +78 -0
  65. package/framework/css/interactions/sequencing.css +378 -0
  66. package/framework/css/interactions/true-false.css +177 -0
  67. package/framework/css/layouts/article.css +258 -0
  68. package/framework/css/layouts/base.css +30 -0
  69. package/framework/css/layouts/canvas.css +38 -0
  70. package/framework/css/layouts/focused.css +236 -0
  71. package/framework/css/layouts/index.css +29 -0
  72. package/framework/css/layouts/presentation.css +191 -0
  73. package/framework/css/layouts/traditional.css +52 -0
  74. package/framework/css/responsive.css +439 -0
  75. package/framework/css/utilities/accessibility-utils.css +59 -0
  76. package/framework/css/utilities/animations.css +419 -0
  77. package/framework/css/utilities/borders.css +72 -0
  78. package/framework/css/utilities/colors.css +76 -0
  79. package/framework/css/utilities/container.css +46 -0
  80. package/framework/css/utilities/decorative.css +442 -0
  81. package/framework/css/utilities/display.css +257 -0
  82. package/framework/css/utilities/flexbox.css +80 -0
  83. package/framework/css/utilities/grid.css +69 -0
  84. package/framework/css/utilities/icons.css +534 -0
  85. package/framework/css/utilities/lists.css +190 -0
  86. package/framework/css/utilities/spacing.css +167 -0
  87. package/framework/css/utilities/tables.css +81 -0
  88. package/framework/css/utilities/typography.css +159 -0
  89. package/framework/css/utilities/visibility.css +117 -0
  90. package/framework/docs/COURSE_AUTHORING_GUIDE.md +1773 -0
  91. package/framework/docs/COURSE_OUTLINE_GUIDE.md +725 -0
  92. package/framework/docs/COURSE_OUTLINE_TEMPLATE.md +161 -0
  93. package/framework/docs/DATA_MODEL.md +409 -0
  94. package/framework/docs/FRAMEWORK_GUIDE.md +1088 -0
  95. package/framework/docs/USER_GUIDE.md +583 -0
  96. package/framework/docs/examples/cloudflare-channel-relay.js +169 -0
  97. package/framework/docs/examples/cloudflare-data-worker.js +102 -0
  98. package/framework/docs/examples/cloudflare-error-worker.js +228 -0
  99. package/framework/index.html +175 -0
  100. package/framework/js/app/AppActions.js +410 -0
  101. package/framework/js/app/AppState.js +225 -0
  102. package/framework/js/app/AppUI.js +616 -0
  103. package/framework/js/assessment/AssessmentActions.js +615 -0
  104. package/framework/js/assessment/AssessmentFactory.js +471 -0
  105. package/framework/js/assessment/AssessmentState.js +322 -0
  106. package/framework/js/assessment/AssessmentUI.js +451 -0
  107. package/framework/js/automation/api-engagement.js +196 -0
  108. package/framework/js/automation/api-interactions.js +167 -0
  109. package/framework/js/automation/api.js +242 -0
  110. package/framework/js/automation/index.js +41 -0
  111. package/framework/js/components/interactions/drag-drop.js +884 -0
  112. package/framework/js/components/interactions/fill-in.js +535 -0
  113. package/framework/js/components/interactions/hotspot.js +702 -0
  114. package/framework/js/components/interactions/interaction-base.js +511 -0
  115. package/framework/js/components/interactions/likert.js +301 -0
  116. package/framework/js/components/interactions/matching.js +699 -0
  117. package/framework/js/components/interactions/multiple-choice.js +377 -0
  118. package/framework/js/components/interactions/numeric.js +271 -0
  119. package/framework/js/components/interactions/sequencing.js +423 -0
  120. package/framework/js/components/interactions/true-false.js +241 -0
  121. package/framework/js/components/ui-components/accordion.js +442 -0
  122. package/framework/js/components/ui-components/alert.js +88 -0
  123. package/framework/js/components/ui-components/audio-player.js +1193 -0
  124. package/framework/js/components/ui-components/callout.js +121 -0
  125. package/framework/js/components/ui-components/carousel.js +145 -0
  126. package/framework/js/components/ui-components/checkbox-group.js +87 -0
  127. package/framework/js/components/ui-components/checklist.js +40 -0
  128. package/framework/js/components/ui-components/collapse.js +114 -0
  129. package/framework/js/components/ui-components/comparison.js +30 -0
  130. package/framework/js/components/ui-components/conditional-display.js +150 -0
  131. package/framework/js/components/ui-components/content-image.js +41 -0
  132. package/framework/js/components/ui-components/dropdown.js +262 -0
  133. package/framework/js/components/ui-components/embed-frame.js +274 -0
  134. package/framework/js/components/ui-components/features.js +33 -0
  135. package/framework/js/components/ui-components/flip-card.js +230 -0
  136. package/framework/js/components/ui-components/form-validator.js +76 -0
  137. package/framework/js/components/ui-components/hero.js +49 -0
  138. package/framework/js/components/ui-components/index.js +12 -0
  139. package/framework/js/components/ui-components/interactive-image.js +235 -0
  140. package/framework/js/components/ui-components/interactive-timeline.js +285 -0
  141. package/framework/js/components/ui-components/intro-cards.js +35 -0
  142. package/framework/js/components/ui-components/lightbox.js +652 -0
  143. package/framework/js/components/ui-components/modal.js +386 -0
  144. package/framework/js/components/ui-components/notifications.js +145 -0
  145. package/framework/js/components/ui-components/progress.js +88 -0
  146. package/framework/js/components/ui-components/quote.js +41 -0
  147. package/framework/js/components/ui-components/stats.js +33 -0
  148. package/framework/js/components/ui-components/steps.js +41 -0
  149. package/framework/js/components/ui-components/tabs.js +255 -0
  150. package/framework/js/components/ui-components/timeline.js +42 -0
  151. package/framework/js/components/ui-components/toggle-group.js +73 -0
  152. package/framework/js/components/ui-components/tooltip.js +458 -0
  153. package/framework/js/components/ui-components/value-display.js +133 -0
  154. package/framework/js/components/ui-components/video-player.js +686 -0
  155. package/framework/js/core/component-catalog.js +121 -0
  156. package/framework/js/core/event-bus.js +178 -0
  157. package/framework/js/core/interaction-catalog.js +149 -0
  158. package/framework/js/dev/runtime-linter.js +1725 -0
  159. package/framework/js/drivers/cmi5-driver.js +768 -0
  160. package/framework/js/drivers/driver-factory.js +77 -0
  161. package/framework/js/drivers/driver-interface.js +110 -0
  162. package/framework/js/drivers/http-driver-base.js +241 -0
  163. package/framework/js/drivers/lti-driver.js +508 -0
  164. package/framework/js/drivers/proxy-driver.js +444 -0
  165. package/framework/js/drivers/scorm-12-driver.js +560 -0
  166. package/framework/js/drivers/scorm-2004-driver.js +775 -0
  167. package/framework/js/drivers/scorm-driver-base.js +112 -0
  168. package/framework/js/engagement/engagement-manager.js +404 -0
  169. package/framework/js/engagement/engagement-progress.js +191 -0
  170. package/framework/js/engagement/engagement-trackers.js +215 -0
  171. package/framework/js/engagement/requirement-strategies.js +268 -0
  172. package/framework/js/main.js +727 -0
  173. package/framework/js/managers/accessibility-manager.js +499 -0
  174. package/framework/js/managers/assessment-manager.js +230 -0
  175. package/framework/js/managers/audio-manager.js +944 -0
  176. package/framework/js/managers/comment-manager.js +88 -0
  177. package/framework/js/managers/flag-manager.js +86 -0
  178. package/framework/js/managers/interaction-manager.js +254 -0
  179. package/framework/js/managers/interaction-registry.js +96 -0
  180. package/framework/js/managers/objective-manager.js +423 -0
  181. package/framework/js/managers/score-manager.js +441 -0
  182. package/framework/js/managers/video-manager.js +536 -0
  183. package/framework/js/navigation/Breadcrumbs.js +234 -0
  184. package/framework/js/navigation/NavigationActions.js +1132 -0
  185. package/framework/js/navigation/NavigationState.js +276 -0
  186. package/framework/js/navigation/NavigationUI.js +574 -0
  187. package/framework/js/navigation/document-gallery.js +357 -0
  188. package/framework/js/navigation/navigation-helpers.js +175 -0
  189. package/framework/js/navigation/navigation-validators.js +174 -0
  190. package/framework/js/state/index.js +8 -0
  191. package/framework/js/state/lms-connection.js +482 -0
  192. package/framework/js/state/lms-error-utils.js +58 -0
  193. package/framework/js/state/state-commits.js +200 -0
  194. package/framework/js/state/state-domains.js +86 -0
  195. package/framework/js/state/state-manager.js +502 -0
  196. package/framework/js/state/state-validation.js +311 -0
  197. package/framework/js/state/transaction-log.js +41 -0
  198. package/framework/js/state/xapi-statement-service.js +325 -0
  199. package/framework/js/utilities/access-control.js +99 -0
  200. package/framework/js/utilities/breakpoint-manager.js +315 -0
  201. package/framework/js/utilities/canvas-slide.js +35 -0
  202. package/framework/js/utilities/conditional-display.js +388 -0
  203. package/framework/js/utilities/course-channel.js +214 -0
  204. package/framework/js/utilities/course-helpers.js +420 -0
  205. package/framework/js/utilities/data-reporter.js +273 -0
  206. package/framework/js/utilities/error-reporter.js +313 -0
  207. package/framework/js/utilities/hotspot-helper.js +341 -0
  208. package/framework/js/utilities/icons.js +348 -0
  209. package/framework/js/utilities/logger.js +92 -0
  210. package/framework/js/utilities/markdown-renderer.js +45 -0
  211. package/framework/js/utilities/scroll-tracker.js +68 -0
  212. package/framework/js/utilities/ui-initializer.js +146 -0
  213. package/framework/js/utilities/utilities.js +293 -0
  214. package/framework/js/utilities/view-manager.js +227 -0
  215. package/framework/js/validation/html-validators.js +422 -0
  216. package/framework/js/validation/scorm-validators.js +438 -0
  217. package/framework/js/vendor/pipwerks.js +931 -0
  218. package/framework/scripts/generate-narration.js +629 -0
  219. package/framework/scripts/tts-providers/azure-provider.js +178 -0
  220. package/framework/scripts/tts-providers/base-provider.js +81 -0
  221. package/framework/scripts/tts-providers/deepgram-provider.js +135 -0
  222. package/framework/scripts/tts-providers/elevenlabs-provider.js +148 -0
  223. package/framework/scripts/tts-providers/google-provider.js +272 -0
  224. package/framework/scripts/tts-providers/index.js +158 -0
  225. package/framework/scripts/tts-providers/openai-provider.js +143 -0
  226. package/framework/version.json +63 -0
  227. package/lib/authoring-api.js +919 -0
  228. package/lib/build-linter.js +450 -0
  229. package/lib/build-packaging.js +186 -0
  230. package/lib/build.js +88 -0
  231. package/lib/cloud.js +691 -0
  232. package/lib/convert.js +341 -0
  233. package/lib/course-parser.js +936 -0
  234. package/lib/course-writer.js +258 -0
  235. package/lib/create.js +248 -0
  236. package/lib/css-index.js +237 -0
  237. package/lib/dev.js +51 -0
  238. package/lib/export-content.js +1246 -0
  239. package/lib/headless-browser.js +413 -0
  240. package/lib/import.js +377 -0
  241. package/lib/index.js +80 -0
  242. package/lib/info.js +79 -0
  243. package/lib/interaction-formatters.js +568 -0
  244. package/lib/manifest/cmi5-manifest.js +63 -0
  245. package/lib/manifest/lti-tool-config.js +53 -0
  246. package/lib/manifest/manifest-factory.js +99 -0
  247. package/lib/manifest/scorm-12-manifest.js +61 -0
  248. package/lib/manifest/scorm-2004-manifest.js +94 -0
  249. package/lib/manifest/scorm-proxy-manifest.js +104 -0
  250. package/lib/manifest-parser.js +96 -0
  251. package/lib/mcp-prompts.js +753 -0
  252. package/lib/mcp-server.js +316 -0
  253. package/lib/narration.js +53 -0
  254. package/lib/pdf-structure.js +142 -0
  255. package/lib/preview-export.js +231 -0
  256. package/lib/preview-routes-api.js +662 -0
  257. package/lib/preview-routes-editing.js +159 -0
  258. package/lib/preview-routes-lms.js +230 -0
  259. package/lib/preview-server.js +564 -0
  260. package/lib/project-utils.js +269 -0
  261. package/lib/proxy-templates/proxy.html +68 -0
  262. package/lib/proxy-templates/scorm-bridge.js +112 -0
  263. package/lib/scaffold.js +193 -0
  264. package/lib/schema-extractor.js +361 -0
  265. package/lib/slide-source-editor.js +586 -0
  266. package/lib/stub-player/app-viewer.js +195 -0
  267. package/lib/stub-player/app.js +370 -0
  268. package/lib/stub-player/catalog-panel.js +312 -0
  269. package/lib/stub-player/config-panel.js +1303 -0
  270. package/lib/stub-player/content-generator.js +586 -0
  271. package/lib/stub-player/content-viewer.js +173 -0
  272. package/lib/stub-player/debug-panel.js +420 -0
  273. package/lib/stub-player/edit-mode.js +922 -0
  274. package/lib/stub-player/edit-utils.js +400 -0
  275. package/lib/stub-player/header-bar.js +354 -0
  276. package/lib/stub-player/interaction-editor.js +210 -0
  277. package/lib/stub-player/interactions-panel.js +565 -0
  278. package/lib/stub-player/lms-api.js +1094 -0
  279. package/lib/stub-player/login-screen.js +74 -0
  280. package/lib/stub-player/outline-mode.js +689 -0
  281. package/lib/stub-player/styles/_assessments-panel.css +245 -0
  282. package/lib/stub-player/styles/_base.css +89 -0
  283. package/lib/stub-player/styles/_catalog-icons.css +96 -0
  284. package/lib/stub-player/styles/_catalog-panel.css +291 -0
  285. package/lib/stub-player/styles/_config-panel.css +636 -0
  286. package/lib/stub-player/styles/_content-viewer.css +834 -0
  287. package/lib/stub-player/styles/_debug-panel.css +576 -0
  288. package/lib/stub-player/styles/_edit-mode.css +128 -0
  289. package/lib/stub-player/styles/_header-bar.css +343 -0
  290. package/lib/stub-player/styles/_interaction-editor.css +140 -0
  291. package/lib/stub-player/styles/_interactions-panel.css +1038 -0
  292. package/lib/stub-player/styles/_login-screen.css +102 -0
  293. package/lib/stub-player/styles/_outline-mode.css +752 -0
  294. package/lib/stub-player/styles.css +15 -0
  295. package/lib/stub-player.js +160 -0
  296. package/lib/test-data-reporting.js +176 -0
  297. package/lib/test-error-reporting.js +146 -0
  298. package/lib/token.js +86 -0
  299. package/lib/upgrade.js +257 -0
  300. package/lib/validation-rules.js +517 -0
  301. package/lib/vite-plugin-content-discovery.js +296 -0
  302. package/package.json +108 -0
  303. package/schemas/XMLSchema.dtd +402 -0
  304. package/schemas/adlcp_v1p3.xsd +111 -0
  305. package/schemas/adlnav_v1p3.xsd +61 -0
  306. package/schemas/adlseq_v1p3.xsd +93 -0
  307. package/schemas/common/anyElement.xsd +27 -0
  308. package/schemas/common/dataTypes.xsd +138 -0
  309. package/schemas/common/elementNames.xsd +767 -0
  310. package/schemas/common/elementTypes.xsd +786 -0
  311. package/schemas/common/rootElement.xsd +31 -0
  312. package/schemas/common/vocabTypes.xsd +345 -0
  313. package/schemas/common/vocabValues.xsd +257 -0
  314. package/schemas/datatypes.dtd +203 -0
  315. package/schemas/ims_xml.xsd +35 -0
  316. package/schemas/imscp_v1p1.xsd +368 -0
  317. package/schemas/imsss_v1p0.xsd +67 -0
  318. package/schemas/imsss_v1p0auxresource.xsd +19 -0
  319. package/schemas/imsss_v1p0control.xsd +20 -0
  320. package/schemas/imsss_v1p0delivery.xsd +17 -0
  321. package/schemas/imsss_v1p0limit.xsd +47 -0
  322. package/schemas/imsss_v1p0objective.xsd +67 -0
  323. package/schemas/imsss_v1p0random.xsd +16 -0
  324. package/schemas/imsss_v1p0rollup.xsd +46 -0
  325. package/schemas/imsss_v1p0seqrule.xsd +108 -0
  326. package/schemas/imsss_v1p0util.xsd +94 -0
  327. package/schemas/license.txt +17 -0
  328. package/schemas/lom.xsd +102 -0
  329. package/schemas/lomCustom.xsd +62 -0
  330. package/schemas/lomLoose.xsd +62 -0
  331. package/schemas/lomStrict.xsd +62 -0
  332. package/schemas/xml.xsd +81 -0
  333. package/template/.env.example +92 -0
  334. package/template/course/assets/audio/example-intro.mp3 +0 -0
  335. package/template/course/assets/audio/example-ui-demo--compact-player.mp3 +0 -0
  336. package/template/course/assets/audio/example-ui-demo--demo-modal.mp3 +0 -0
  337. package/template/course/assets/audio/example-ui-demo--full-player.mp3 +0 -0
  338. package/template/course/assets/docs/example_md_1.md +39 -0
  339. package/template/course/assets/docs/example_md_2.md +41 -0
  340. package/template/course/assets/docs/example_pdf_1_thumbnail.png +0 -0
  341. package/template/course/assets/docs/example_pdf_2.pdf +0 -0
  342. package/template/course/assets/images/course-architecture.svg +36 -0
  343. package/template/course/assets/images/logo.svg +14 -0
  344. package/template/course/assets/widgets/counter-demo.html +190 -0
  345. package/template/course/assets/widgets/gravity-painter.html +384 -0
  346. package/template/course/course-config.js +539 -0
  347. package/template/course/icons.js +19 -0
  348. package/template/course/interactions/PLUGIN_GUIDE.md +97 -0
  349. package/template/course/slides/example-course-structure.js +138 -0
  350. package/template/course/slides/example-final-exam.js +144 -0
  351. package/template/course/slides/example-finishing.js +127 -0
  352. package/template/course/slides/example-interactions-showcase.js +615 -0
  353. package/template/course/slides/example-preview-tour.js +129 -0
  354. package/template/course/slides/example-remedial.js +143 -0
  355. package/template/course/slides/example-summary.js +103 -0
  356. package/template/course/slides/example-ui-showcase.js +1805 -0
  357. package/template/course/slides/example-welcome.js +123 -0
  358. package/template/course/slides/example-workflow.js +140 -0
  359. package/template/course/theme.css +165 -0
  360. package/template/eslint.config.js +47 -0
  361. package/template/package.json +28 -0
  362. package/template/vite.config.js +339 -0
@@ -0,0 +1,1132 @@
1
+ /**
2
+ * @file NavigationActions.js
3
+ * @description Handles user interactions for course navigation and coordinates between state and UI.
4
+ * @author Seth
5
+ * @version 2.0.0
6
+ */
7
+
8
+ import * as NavigationState from './NavigationState.js';
9
+ import * as NavigationUI from './NavigationUI.js';
10
+ import {
11
+ shouldBypassEngagement
12
+ } from './navigation-helpers.js';
13
+ import {
14
+ isSlideInSequence,
15
+ validateSlideAccess,
16
+ validateNavigationFrom
17
+ } from './navigation-validators.js';
18
+ import stateManager from '../state/index.js';
19
+ import * as CourseHelpers from '../utilities/course-helpers.js';
20
+ import * as AssessmentManager from '../managers/assessment-manager.js';
21
+ import * as AppActions from '../app/AppActions.js';
22
+ import * as AppUI from '../app/AppUI.js';
23
+ import { eventBus } from '../core/event-bus.js';
24
+ import engagementManager from '../engagement/engagement-manager.js';
25
+ import { logger } from '../utilities/logger.js';
26
+
27
+
28
+ let slides = [];
29
+ let menuTree = [];
30
+ let viewManager;
31
+ let assessmentConfigs = new Map();
32
+ let navigationLocked = false;
33
+ let isInitialized = false;
34
+
35
+ // Navigation queue for handling async navigation requests
36
+ const navigationQueue = [];
37
+ let isNavigating = false;
38
+
39
+ // Engagement progress handlers for current slide
40
+ let currentEngagementProgressHandler = null;
41
+ let currentEngagementCompleteHandler = null;
42
+
43
+ // ===== ERROR HANDLING =====
44
+
45
+ /**
46
+ * Creates a standardized navigation error and emits error event.
47
+ * Use this for actual system errors, NOT for expected user-facing blocks.
48
+ * @private
49
+ * @param {string} operation - The operation that failed
50
+ * @param {string} message - Error message
51
+ * @param {object} context - Additional context for debugging
52
+ * @returns {Error} The created error object
53
+ */
54
+ function _createNavigationError(operation, message, context = {}) {
55
+ const error = new Error(`Navigation failed: ${message}`);
56
+ logger.error(error.message, { domain: 'navigation', operation, stack: error.stack, ...context });
57
+ return error;
58
+ }
59
+
60
+ /**
61
+ * Creates a navigation block error WITHOUT emitting to error reporter.
62
+ * Use this for expected user-facing blocks (gating conditions, sequence exclusions)
63
+ * where the user is simply trying to access locked content.
64
+ * @private
65
+ * @param {string} message - User-facing message (already shown via notification)
66
+ * @returns {Error} The created error object
67
+ */
68
+ function _createNavigationBlockError(message) {
69
+ return new Error(`Navigation blocked: ${message}`);
70
+ }
71
+
72
+ // ===== INITIALIZATION =====
73
+
74
+ /**
75
+ * Initializes the navigation actions module.
76
+ * Caches the course slide data and sets up event listeners for navigation controls.
77
+ * @param {object[]} courseSlides - The course slide configuration array from `course-config.js`.
78
+ * @param {object} viewManagerInstance - The view manager instance.
79
+ * @param {object[]} courseMenuTree - The hierarchical menu tree from getMenuTree().
80
+ * @param {Map} courseAssessmentConfigs - A map of assessment configurations from getAssessmentConfigs().
81
+ */
82
+ export async function init(courseSlides, viewManagerInstance, courseMenuTree = [], courseAssessmentConfigs = new Map()) {
83
+ if (!courseSlides || !Array.isArray(courseSlides)) {
84
+ throw new Error('NavigationActions.init() requires a valid slides array');
85
+ }
86
+ if (!viewManagerInstance) {
87
+ throw new Error('NavigationActions.init() requires a valid viewManager instance');
88
+ }
89
+
90
+ slides = courseSlides;
91
+ viewManager = viewManagerInstance;
92
+ menuTree = courseMenuTree;
93
+ assessmentConfigs = courseAssessmentConfigs;
94
+ navigationLocked = false;
95
+ isInitialized = true;
96
+
97
+ // Initialize NavigationState (no longer needs slides parameter)
98
+ NavigationState.initializeNavigationState();
99
+
100
+ // Store slides reference in NavigationState for getCurrentSlideId()
101
+ NavigationState.setSlidesReference(courseSlides);
102
+
103
+ // Render the menu on initialization
104
+ const visitedSlides = NavigationState.getVisitedSlides();
105
+ const accessibilityMap = checkAllSlidesAccessibility();
106
+ NavigationUI.renderMenu(menuTree, visitedSlides, accessibilityMap);
107
+
108
+ // Set up a single delegated event listener for all navigation controls
109
+ document.body.addEventListener('click', (event) => {
110
+ const actionTarget = event.target.closest('[data-action]');
111
+ if (!actionTarget) return;
112
+
113
+ const action = actionTarget.dataset.action;
114
+
115
+ if (navigationLocked) {
116
+ event.preventDefault();
117
+ return;
118
+ }
119
+
120
+ switch (action) {
121
+ case 'nav-menu-item':
122
+ _handleMenuClick(event);
123
+ break;
124
+ case 'nav-prev':
125
+ _handlePrevClick();
126
+ break;
127
+ case 'nav-next':
128
+ _handleNextClick();
129
+ break;
130
+ }
131
+ });
132
+
133
+ // Re-sync navigation when state changes that might affect gating
134
+ // Listen for state changes in domains that affect gating (assessment_*, objectives, flags)
135
+ eventBus.on('state:changed', ({ domain }) => {
136
+ // Only re-sync if the changed domain could affect gating conditions
137
+ if (domain.startsWith('assessment_') || domain === 'objectives' || domain === 'flags') {
138
+ sync();
139
+ }
140
+ });
141
+
142
+ eventBus.on('ui:lockCourseForExit', () => {
143
+ navigationLocked = true;
144
+ });
145
+
146
+ // Determine initial slide based on session type
147
+ // - RESUME (cmi.entry === 'resume'): Use cmi.location (SCORM standard bookmark)
148
+ // - FIRST LAUNCH (cmi.entry !== 'resume'): Use slide 0 (default start)
149
+ let initialSlideId = null;
150
+ const resumeSlideId = NavigationState.getResumeSlideId();
151
+
152
+ if (resumeSlideId) {
153
+ // RESUME: cmi.location contains bookmark (NavigationState already validated it's not empty)
154
+ logger.debug('[NavigationActions] Resume session. Using cmi.location bookmark:', resumeSlideId);
155
+
156
+ // Validate that the bookmarked slide exists in current course structure
157
+ const resumeSlideIndex = await CourseHelpers.getSlideIndex(resumeSlideId);
158
+ if (resumeSlideIndex === null || resumeSlideIndex === undefined) {
159
+ // Bookmark references non-existent slide (course structure changed or corrupted)
160
+ const errorMessage = `Invalid bookmark in cmi.location: slide "${resumeSlideId}" not found in course structure. ` +
161
+ 'This indicates the course structure has changed since the bookmark was created.';
162
+ const errorContext = {
163
+ resumeSlideId,
164
+ availableSlides: slides.map(s => s.id).slice(0, 10), // First 10 for debugging
165
+ totalSlides: slides.length
166
+ };
167
+
168
+ if (import.meta.env.DEV) {
169
+ // Dev mode: FAIL FAST to help developers identify stale data issues
170
+ throw _createNavigationError('resume', errorMessage, errorContext);
171
+ } else {
172
+ // Production mode: Gracefully recover by starting from the beginning
173
+ logger.warn(`[NavigationActions] ${errorMessage} Reverting to slide 0.`);
174
+ eventBus.emit('state:recovered', {
175
+ domain: 'navigation',
176
+ message: errorMessage,
177
+ context: errorContext,
178
+ action: 'reverted_to_slide_0'
179
+ });
180
+ // Fall through to FIRST LAUNCH behavior
181
+ const initialIndex = 0;
182
+ NavigationState.setCurrentSlideIndex(initialIndex);
183
+ initialSlideId = slides[initialIndex]?.id;
184
+ NavigationState.clearResumeSlideId();
185
+ }
186
+ } else {
187
+ initialSlideId = resumeSlideId;
188
+ // Update internal state to match cmi.location
189
+ NavigationState.setCurrentSlideIndex(resumeSlideIndex);
190
+
191
+ // Clear resume flag after processing
192
+ NavigationState.clearResumeSlideId();
193
+ }
194
+ } else {
195
+ // FIRST LAUNCH: Start at beginning (currentSlideIndex defaults to 0 in NavigationState)
196
+ const initialIndex = NavigationState.getCurrentSlideIndex();
197
+ initialSlideId = slides[initialIndex]?.id;
198
+ logger.debug('[NavigationActions] First launch. Starting at slide index:', initialIndex);
199
+ }
200
+
201
+ if (!initialSlideId) {
202
+ throw _createNavigationError(
203
+ 'initial-load',
204
+ 'Could not determine initial slide. Check course-config.js structure.',
205
+ { resumeSlideId, currentIndex: NavigationState.getCurrentSlideIndex() }
206
+ );
207
+ }
208
+
209
+ try {
210
+ // Navigate to initial slide and set bookmark
211
+ // Pass updateBookmark=true to ensure cmi.location is set after initialization completes
212
+ // This is critical for both first launch (set initial bookmark) and resume (confirm successful navigation)
213
+ await goToSlide(initialSlideId, {}, true);
214
+ } catch (error) {
215
+ // Check if this is a navigation block (gating/sequence issue on resume)
216
+ const isBlockError = error.message?.includes('Navigation blocked:');
217
+
218
+ if (isBlockError && resumeSlideId) {
219
+ // Resume attempted to navigate to a now-gated slide
220
+ // This should NOT happen in normal operation - it indicates:
221
+ // 1. Course structure changed after learner started
222
+ // 2. Prerequisite state was lost/corrupted
223
+ // 3. A bug in bookmark setting (bookmark set before gating check)
224
+
225
+ if (import.meta.env.DEV) {
226
+ // DEV MODE: FAIL FAST to help identify the root cause
227
+ // The bookmark should NEVER be set to a slide that gating would block
228
+ throw _createNavigationError(
229
+ 'resume',
230
+ 'BOOKMARK INCONSISTENCY: cmi.location="' + resumeSlideId + '" points to a gated slide. ' +
231
+ 'This indicates either: (1) course structure changed after learner started, ' +
232
+ '(2) prerequisite state was lost, or (3) a bug in bookmark setting. ' +
233
+ 'Original error: ' + error.message,
234
+ {
235
+ bookmarkedSlide: resumeSlideId,
236
+ blockReason: error.message
237
+ }
238
+ );
239
+ }
240
+
241
+ // PRODUCTION: Gracefully recover by finding the first accessible slide
242
+ // BUT still report this as an error - it indicates LMS/course state corruption
243
+ logger.warn(`[NavigationActions] Bookmarked slide "${initialSlideId}" is now gated. Finding first accessible slide.`);
244
+
245
+ const fallbackSlide = _findFirstAccessibleSlide();
246
+ if (fallbackSlide) {
247
+ // Report this anomaly - graceful recovery doesn't mean it's not a problem
248
+ logger.error(`Bookmark inconsistency: cmi.location="${initialSlideId}" points to a gated slide. Recovered to "${fallbackSlide.id}".`, {
249
+ domain: 'navigation', operation: 'resume',
250
+ bookmarkedSlide: initialSlideId, fallbackSlide: fallbackSlide.id,
251
+ blockReason: error.message, action: 'graceful_recovery'
252
+ });
253
+
254
+ eventBus.emit('state:recovered', {
255
+ domain: 'navigation',
256
+ message: `Bookmarked slide "${initialSlideId}" is no longer accessible. Starting from "${fallbackSlide.id}".`,
257
+ context: {
258
+ originalSlide: initialSlideId,
259
+ fallbackSlide: fallbackSlide.id,
260
+ reason: 'gating-condition-on-resume'
261
+ },
262
+ action: 'reverted_to_accessible_slide'
263
+ });
264
+
265
+ // Try navigating to the fallback slide
266
+ await goToSlide(fallbackSlide.id, {}, true);
267
+ logger.debug('NavigationActions initialized (with fallback to accessible slide).');
268
+ return; // Success with fallback
269
+ }
270
+
271
+ // No accessible slides found - this is a course configuration error
272
+ throw _createNavigationError(
273
+ 'initial-load',
274
+ 'No accessible slides found. Check course gating configuration.',
275
+ { originalSlide: initialSlideId }
276
+ );
277
+ }
278
+
279
+ // For other errors, emit and re-throw
280
+ logger.error(error.message, { domain: 'navigation', operation: 'initial-load', stack: error.stack, slideId: initialSlideId });
281
+ throw error;
282
+ }
283
+
284
+ logger.debug('NavigationActions initialized.');
285
+ }
286
+
287
+ /**
288
+ * Finds the first slide that is accessible (passes gating conditions).
289
+ * @private
290
+ * @returns {object|null} The first accessible slide, or null if none found
291
+ */
292
+ function _findFirstAccessibleSlide() {
293
+ for (const slide of slides) {
294
+ // Check if slide is in sequence
295
+ if (!isSlideInSequence(slide, stateManager, assessmentConfigs)) {
296
+ continue;
297
+ }
298
+
299
+ // Check if slide passes gating conditions
300
+ const accessCheck = validateSlideAccess(slide, stateManager, assessmentConfigs);
301
+ if (accessCheck.allowed) {
302
+ return slide;
303
+ }
304
+ }
305
+ return null;
306
+ }
307
+
308
+ /**
309
+ * Throws an error if NavigationActions has not been initialized.
310
+ * @private
311
+ */
312
+ function _requireInitialized() {
313
+ if (!isInitialized) {
314
+ throw new Error('NavigationActions not initialized. Call init() first.');
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Indicates whether NavigationActions has finished initialization.
320
+ * @returns {boolean}
321
+ */
322
+ export function isReady() {
323
+ return isInitialized;
324
+ }
325
+
326
+
327
+ // ===== ENGAGEMENT TRACKING =====
328
+
329
+ /**
330
+ * Updates the engagement indicator UI for the current slide.
331
+ * Shows/hides the indicator and updates progress based on slide config.
332
+ * @private
333
+ * @param {object} slide - The current slide object
334
+ */
335
+ function _updateEngagementIndicator(slide) {
336
+ if (!slide || !slide.engagement) {
337
+ NavigationUI.hideEngagementIndicator();
338
+ return;
339
+ }
340
+
341
+ const engagement = slide.engagement;
342
+
343
+ // Show indicator if engagement is required (showIndicator defaults to true)
344
+ const showIndicator = engagement.showIndicator ?? true;
345
+ if (engagement.required && showIndicator) {
346
+ const progress = engagementManager.getProgress(slide.id);
347
+ if (progress) {
348
+ NavigationUI.showEngagementIndicator(progress);
349
+ }
350
+ } else {
351
+ NavigationUI.hideEngagementIndicator();
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Sets up engagement tracking event listeners for the current slide.
357
+ * Cleans up previous listeners and attaches new ones for progress updates.
358
+ * @private
359
+ * @param {object} slide - The current slide object
360
+ */
361
+ function _setupEngagementListeners(slide) {
362
+ // Clean up previous listeners if they exist
363
+ if (currentEngagementProgressHandler) {
364
+ eventBus.off('engagement:progress', currentEngagementProgressHandler);
365
+ }
366
+ if (currentEngagementCompleteHandler) {
367
+ eventBus.off('engagement:complete', currentEngagementCompleteHandler);
368
+ }
369
+
370
+ // Set up listeners if engagement tracking is required (regardless of indicator visibility)
371
+ if (slide && slide.engagement && slide.engagement.required) {
372
+ // Progress handler - updates indicator and navigation state in real-time
373
+ currentEngagementProgressHandler = ({ slideId, progress }) => {
374
+ if (slideId === slide.id) {
375
+ // Update indicator if visible (defaults to true)
376
+ if (slide.engagement.showIndicator ?? true) {
377
+ NavigationUI.showEngagementIndicator(progress);
378
+ }
379
+ // Always sync navigation state since completion affects sidebar/buttons
380
+ sync();
381
+ }
382
+ };
383
+
384
+ // Complete handler - triggers when all requirements are met
385
+ currentEngagementCompleteHandler = ({ slideId }) => {
386
+ if (slideId === slide.id) {
387
+ // Trigger completion animation directly
388
+ NavigationUI.triggerEngagementCompleteAnimation();
389
+
390
+ // Update indicator if visible (defaults to true)
391
+ if (slide.engagement.showIndicator ?? true) {
392
+ const progress = engagementManager.getProgress(slideId);
393
+ if (progress) {
394
+ NavigationUI.showEngagementIndicator(progress);
395
+ }
396
+ }
397
+ // Always sync navigation state to enable next button and unlock sidebar items
398
+ sync();
399
+ }
400
+ };
401
+
402
+ eventBus.on('engagement:progress', currentEngagementProgressHandler);
403
+ eventBus.on('engagement:complete', currentEngagementCompleteHandler);
404
+
405
+ // Time tracking is now handled by EngagementManager internally
406
+ // It emits engagement:progress events which we listen to above
407
+ }
408
+ }
409
+
410
+
411
+ /**
412
+ * Resolves the next slide that should appear in the sequential flow, skipping
413
+ * any slides that are currently excluded by sequence rules.
414
+ * @private
415
+ * @param {number} currentIndex - Index of the current slide.
416
+ * @returns {{index: number|null, slide: object|null, accessCheck: {allowed: boolean, message: string|null}}}
417
+ */
418
+ function _getNextIncludedSlideInfo(currentIndex) {
419
+ for (let i = currentIndex + 1; i < slides.length; i++) {
420
+ const candidate = slides[i];
421
+ if (!isSlideInSequence(candidate, stateManager, assessmentConfigs)) {
422
+ continue;
423
+ }
424
+
425
+ return {
426
+ index: i,
427
+ slide: candidate,
428
+ accessCheck: validateSlideAccess(candidate, stateManager, assessmentConfigs),
429
+ };
430
+ }
431
+
432
+ return {
433
+ index: null,
434
+ slide: null,
435
+ accessCheck: { allowed: true, message: null },
436
+ };
437
+ }
438
+
439
+ /**
440
+ * Resolves the previous slide in the sequential flow, skipping excluded slides.
441
+ * @private
442
+ * @param {number} currentIndex - Index of the current slide.
443
+ * @returns {{index: number|null, slide: object|null, accessCheck: {allowed: boolean, message: string|null}}}
444
+ */
445
+ function _getPreviousIncludedSlideInfo(currentIndex) {
446
+ for (let i = currentIndex - 1; i >= 0; i--) {
447
+ const candidate = slides[i];
448
+ if (!isSlideInSequence(candidate, stateManager, assessmentConfigs)) {
449
+ continue;
450
+ }
451
+
452
+ return {
453
+ index: i,
454
+ slide: candidate,
455
+ accessCheck: validateSlideAccess(candidate, stateManager, assessmentConfigs),
456
+ };
457
+ }
458
+
459
+ return {
460
+ index: null,
461
+ slide: null,
462
+ accessCheck: { allowed: true, message: null },
463
+ };
464
+ }
465
+
466
+
467
+ /**
468
+ * Handles click events on the navigation menu, delegating to `navigateToSlide`.
469
+ * @private
470
+ * @param {Event} event - The DOM click event.
471
+ */
472
+ function _handleMenuClick(event) {
473
+ event.preventDefault();
474
+ const target = event.target.closest('[data-action="nav-menu-item"]');
475
+ if (!target) return;
476
+
477
+ // Do not navigate if the item is locked
478
+ // Check both .locked class and aria-disabled attribute for robustness
479
+ const link = target.querySelector('button');
480
+ if (target.classList.contains('locked') || (link && link.getAttribute('aria-disabled') === 'true')) {
481
+ // Tooltip will show on hover to explain why it's locked
482
+ return;
483
+ }
484
+
485
+ const slideId = target.dataset.slideId;
486
+ if (slideId) {
487
+ goToSlide(slideId).catch(error => {
488
+ // Error is already emitted via eventBus in goToSlide,
489
+ // but we catch it here to prevent unhandled promise rejection
490
+ logger.warn('Navigation failed:', error.message);
491
+ });
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Handles clicks on the 'previous' button.
497
+ * @private
498
+ */
499
+ function _handlePrevClick() {
500
+ goToPreviousAvailableSlide().catch(error => {
501
+ logger.warn('Navigation failed:', error.message);
502
+ });
503
+ }
504
+
505
+ /**
506
+ * Handles clicks on the 'next' button.
507
+ * @private
508
+ */
509
+ function _handleNextClick() {
510
+ goToNextAvailableSlide().catch(error => {
511
+ logger.warn('Navigation failed:', error.message);
512
+ });
513
+ }
514
+
515
+
516
+
517
+ /**
518
+ * Navigates to a specific slide by its ID, handling all accessibility and timing logic.
519
+ * This is the primary and sole function for all course navigation.
520
+ * Uses a queue to serialize navigation requests and prevent race conditions.
521
+ * @param {string} slideId - The ID of the slide to navigate to.
522
+ * @param {object} [context={}] - An optional context object to pass to the slide's render function.
523
+ */
524
+ export async function goToSlide(slideId, context = {}, updateBookmark = true) {
525
+ _requireInitialized();
526
+
527
+ // Queue the navigation request to prevent race conditions
528
+ return new Promise((resolve, reject) => {
529
+ navigationQueue.push({ slideId, context, updateBookmark, resolve, reject });
530
+ _processNavigationQueue();
531
+ });
532
+ }
533
+
534
+ /**
535
+ * Processes the navigation queue, executing one navigation request at a time.
536
+ * @private
537
+ */
538
+ async function _processNavigationQueue() {
539
+ if (isNavigating || navigationQueue.length === 0) {
540
+ return;
541
+ }
542
+
543
+ isNavigating = true;
544
+ const { slideId, context, updateBookmark, resolve, reject } = navigationQueue.shift();
545
+
546
+ try {
547
+ await _performNavigation(slideId, context, updateBookmark);
548
+ resolve();
549
+ } catch (error) {
550
+ reject(error);
551
+ } finally {
552
+ isNavigating = false;
553
+ _processNavigationQueue(); // Process next request in queue
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Performs the actual navigation to a slide. This is the internal implementation.
559
+ * @private
560
+ * @param {string} slideId - The ID of the slide to navigate to.
561
+ * @param {object} [context={}] - An optional context object to pass to the slide's render function.
562
+ */
563
+ async function _performNavigation(slideId, context = {}, updateBookmark = true) {
564
+ if (navigationLocked) {
565
+ throw _createNavigationError(
566
+ 'goToSlide',
567
+ 'Navigation is locked. Course is in exit process.',
568
+ { slideId }
569
+ );
570
+ }
571
+
572
+ const slideIndex = await CourseHelpers.getSlideIndex(slideId);
573
+ if (slideIndex === null || slideIndex === undefined) {
574
+ throw _createNavigationError(
575
+ 'goToSlide',
576
+ `Slide "${slideId}" not found in course structure. Check that the slide ID exists in course-config.js.`,
577
+ {
578
+ slideId,
579
+ availableSlides: slides.map(s => s.id)
580
+ }
581
+ );
582
+ }
583
+
584
+ const newSlide = slides[slideIndex];
585
+ if (!newSlide) {
586
+ throw _createNavigationError(
587
+ 'goToSlide',
588
+ `Slide at index ${slideIndex} is undefined. This indicates a data consistency issue.`,
589
+ { slideId, slideIndex }
590
+ );
591
+ }
592
+
593
+ if (!isSlideInSequence(newSlide, stateManager, assessmentConfigs)) {
594
+ const sequenceMessage = newSlide.navigation?.sequence?.message || 'This content is not available right now.';
595
+ AppActions.showNotification(sequenceMessage, 'info', 3000);
596
+ eventBus.emit('navigation:blocked', {
597
+ slideId: newSlide.id,
598
+ slideIndex,
599
+ message: sequenceMessage,
600
+ reason: 'sequence-excluded',
601
+ });
602
+ // Use block error (not system error) - user is trying to access excluded content
603
+ throw _createNavigationBlockError(sequenceMessage);
604
+ }
605
+
606
+ // Check if the destination slide is accessible (gating conditions)
607
+ const accessCheck = validateSlideAccess(newSlide, stateManager, assessmentConfigs);
608
+ if (!accessCheck.allowed) {
609
+ AppActions.showNotification(accessCheck.message, 'info', 3000);
610
+ eventBus.emit('navigation:blocked', {
611
+ slideId: newSlide.id,
612
+ slideIndex: slideIndex,
613
+ message: accessCheck.message,
614
+ reason: 'gating-condition'
615
+ });
616
+ // Use block error (not system error) - user is trying to skip locked content
617
+ throw _createNavigationBlockError(accessCheck.message);
618
+ }
619
+
620
+ // Announce that navigation is about to happen and mark the PREVIOUS slide as visited.
621
+ const previousSlideIndex = NavigationState.getCurrentSlideIndex();
622
+ let previousSlideId = null;
623
+ if (previousSlideIndex !== slideIndex) {
624
+ const previousSlide = slides[previousSlideIndex];
625
+ if (previousSlide) {
626
+ previousSlideId = previousSlide.id;
627
+ // Mark the slide we are LEAVING as visited.
628
+ NavigationState.addVisitedSlide(previousSlide.id);
629
+ sync(); // Run sync to update the UI for the slide we just left.
630
+
631
+ eventBus.emit('navigation:beforeChange', { fromSlideId: previousSlide.id });
632
+ }
633
+ }
634
+
635
+ // Update UI for the NEW slide
636
+ NavigationUI.setActiveItem(newSlide.id);
637
+
638
+ // Restore footer visibility before showing new slide
639
+ // Assessments will hide it again if needed during their initialization
640
+ AppUI.showFooter();
641
+
642
+ // Update current slide index BEFORE rendering so that declarative components
643
+ // (tabs, accordion, etc.) can correctly use getCurrentSlideId() during initialization.
644
+ // This ensures engagement tracking registers to the correct slide.
645
+ NavigationState.setCurrentSlideIndex(slideIndex);
646
+
647
+ // Show the slide view (this calls initSlide which registers interactions)
648
+ await viewManager.showView(newSlide.id, { ...context, fromSlide: previousSlideId });
649
+
650
+ // Reset scroll position to top of new slide
651
+ // Users expect to start reading from the top when navigating to a new slide
652
+ const contentArea = document.querySelector('main#content');
653
+ if (contentArea) {
654
+ contentArea.scrollTo(0, 0);
655
+ }
656
+
657
+ // Check engagement completion after slide has been initialized
658
+ const canNavigateFrom = validateNavigationFrom(newSlide, assessmentConfigs);
659
+ const nextInfo = _getNextIncludedSlideInfo(slideIndex);
660
+ const isNextAccessible = nextInfo.accessCheck;
661
+
662
+ // Check engagement requirements (with dev mode bypass)
663
+ let engagementComplete = true;
664
+ let engagementProgress = null;
665
+
666
+ if (!shouldBypassEngagement()) {
667
+ const engagementEvaluation = engagementManager.evaluateRequirements(newSlide.id);
668
+ engagementComplete = engagementEvaluation.complete;
669
+ engagementProgress = engagementEvaluation.progress;
670
+ }
671
+
672
+ const nextBlocked = !engagementComplete || !canNavigateFrom.allowed || !isNextAccessible.allowed;
673
+ const nextBlockedMessage = !engagementComplete
674
+ ? engagementProgress?.tooltip
675
+ : (canNavigateFrom.message || isNextAccessible.message);
676
+
677
+ NavigationUI.updateNavButtonState({
678
+ isFirstSlide: slideIndex === 0,
679
+ isLastSlide: nextInfo.slide === null,
680
+ nextBlocked,
681
+ nextBlockedMessage,
682
+ engagementProgress: engagementProgress?.percentage ?? null,
683
+ });
684
+
685
+ // Update header progress indicator
686
+ const sequentialSlides = slides.filter(s => isSlideInSequence(s, stateManager, assessmentConfigs));
687
+ const currentSequentialIndex = sequentialSlides.findIndex(s => s.id === slideId);
688
+ const visitedCount = NavigationState.getVisitedSlides().filter(id => sequentialSlides.some(s => s.id === id)).length;
689
+ NavigationUI.updateHeaderProgress(currentSequentialIndex >= 0 ? currentSequentialIndex : slideIndex, sequentialSlides.length, visitedCount);
690
+
691
+ // NOTE: Do NOT mark the new slide as visited here.
692
+ // Slides are marked as visited when the user LEAVES them,
693
+ // so that cmi.progress_measure accurately reflects completed content, not just entered content.
694
+
695
+ // Set up engagement indicator and listeners for the new slide
696
+ _setupEngagementListeners(newSlide);
697
+ _updateEngagementIndicator(newSlide);
698
+
699
+ // Start timer for the new slide
700
+ AppActions.startSessionTimer(slideId);
701
+
702
+ // Update progress measure to reflect slide visit
703
+ // Count only sequential slides (excludes remedial/conditional slides)
704
+ const totalSequentialSlides = slides.filter(s => isSlideInSequence(s, stateManager, assessmentConfigs)).length;
705
+ stateManager.updateProgressMeasure(totalSequentialSlides);
706
+
707
+ // Set bookmark as FINAL step after successful navigation
708
+ // We use slide ID as the bookmark value (unique, stable, human-readable)
709
+ // This is done LAST to ensure we only bookmark after we've successfully navigated
710
+ if (updateBookmark) {
711
+ try {
712
+ stateManager.setBookmark(newSlide.id);
713
+ } catch (error) {
714
+ // FAIL FAST, FAIL LOUD: Report via unified logger and re-throw
715
+ logger.error(`Failed to set bookmark: ${error.message}`, {
716
+ domain: 'navigation', operation: 'setBookmark', stack: error.stack,
717
+ slideId: newSlide.id, slideIndex
718
+ });
719
+ throw error; // Re-throw to halt navigation
720
+ }
721
+ }
722
+
723
+ // Sync navigation state after slide is fully loaded
724
+ // This ensures button states reflect any engagement tracking that happened during slide render
725
+ sync();
726
+
727
+ // Announce that navigation has completed.
728
+ // Include fromSlideId so xapi-statement-service can send 'experienced' statements
729
+ eventBus.emit('navigation:changed', { fromSlideId: previousSlideId, toSlideId: newSlide.id, slideTitle: newSlide.title || null });
730
+
731
+ // Announce that a navigation change may trigger a completion check.
732
+ eventBus.emit('navigation:completeCheck');
733
+ }
734
+
735
+ /**
736
+ * Resets the navigation state, marking all slides as unvisited and setting the current slide to the first one.
737
+ * Useful for restarting the course or resetting progress.
738
+ */
739
+ export function resetNavigation() {
740
+ _requireInitialized();
741
+
742
+ // Reset the internal state
743
+ NavigationState.setCurrentSlideIndex(0);
744
+ NavigationState.clearVisitedSlides();
745
+
746
+ // Update the UI to reflect the reset state
747
+ NavigationUI.setActiveItem(slides[0]?.id);
748
+
749
+ const firstSlide = slides[0];
750
+ const navigationCheck = validateNavigationFrom(firstSlide, assessmentConfigs);
751
+ NavigationUI.updateNavButtonState({
752
+ isFirstSlide: true,
753
+ isLastSlide: slides.length === 1,
754
+ nextBlocked: !navigationCheck.allowed,
755
+ nextBlockedMessage: navigationCheck.message,
756
+ });
757
+
758
+ // Optionally, you could also trigger a view refresh
759
+ viewManager.showView(slides[0]?.id);
760
+ }
761
+
762
+ /**
763
+ * Checks the accessibility of all slides based on their gating conditions AND engagement requirements.
764
+ * A slide is inaccessible if:
765
+ * 1. It's excluded by sequence rules
766
+ * 2. Its gating conditions are not met
767
+ * 3. Any previous slide in the sequence has incomplete engagement requirements
768
+ * @returns {Map<string, {allowed: boolean, message: string|null}>} A map of slide accessibility states.
769
+ */
770
+ export function checkAllSlidesAccessibility() {
771
+ _requireInitialized();
772
+
773
+ const accessibilityMap = new Map();
774
+ const visitedSlides = NavigationState.getVisitedSlides();
775
+ const currentIndex = NavigationState.getCurrentSlideIndex();
776
+ const currentSlide = slides[currentIndex];
777
+
778
+ // Build the accessibility map
779
+ slides.forEach((slide, index) => {
780
+ const include = isSlideInSequence(slide, stateManager, assessmentConfigs);
781
+
782
+ if (!include) {
783
+ accessibilityMap.set(slide.id, {
784
+ allowed: false,
785
+ message: slide.navigation?.sequence?.message || 'This content is not available right now.'
786
+ });
787
+ return;
788
+ }
789
+
790
+ // Check gating conditions
791
+ const accessCheck = validateSlideAccess(slide, stateManager, assessmentConfigs);
792
+ if (!accessCheck.allowed) {
793
+ accessibilityMap.set(slide.id, accessCheck);
794
+ return;
795
+ }
796
+
797
+ // Current slide is always accessible (we're already on it)
798
+ if (currentSlide && slide.id === currentSlide.id) {
799
+ accessibilityMap.set(slide.id, { allowed: true, message: null });
800
+ return;
801
+ }
802
+
803
+ // Check if any PREVIOUS slide (visited or not) has required engagement
804
+ // If a slide hasn't been visited but has required engagement, it blocks forward navigation
805
+ // Check previous engagement (with dev mode bypass)
806
+ let hasIncompleteEngagement = false;
807
+ let incompleteSlideTitle = null;
808
+
809
+ if (!shouldBypassEngagement()) {
810
+ for (let i = 0; i < index; i++) {
811
+ const previousSlide = slides[i];
812
+
813
+ // Only check slides that are in sequence
814
+ if (isSlideInSequence(previousSlide, stateManager, assessmentConfigs)) {
815
+ // Check if this slide has required engagement
816
+ const slideEngagement = previousSlide.engagement;
817
+ const hasRequiredEngagement = slideEngagement && slideEngagement.required === true;
818
+
819
+ if (hasRequiredEngagement) {
820
+ // If visited, check if engagement is complete
821
+ if (visitedSlides.includes(previousSlide.id)) {
822
+ const evaluation = engagementManager.evaluateRequirements(previousSlide.id);
823
+ if (!evaluation.complete) {
824
+ hasIncompleteEngagement = true;
825
+ incompleteSlideTitle = previousSlide.title || previousSlide.id;
826
+ break;
827
+ }
828
+ } else {
829
+ // Not visited but has required engagement - blocks forward navigation
830
+ hasIncompleteEngagement = true;
831
+ incompleteSlideTitle = previousSlide.title || previousSlide.id;
832
+ break;
833
+ }
834
+ }
835
+ }
836
+ }
837
+ }
838
+
839
+ if (hasIncompleteEngagement) {
840
+ const message = incompleteSlideTitle
841
+ ? `Complete all required content in "${incompleteSlideTitle}" before continuing.`
842
+ : 'Complete all required content on previous slides before continuing.';
843
+ accessibilityMap.set(slide.id, {
844
+ allowed: false,
845
+ message
846
+ });
847
+ return;
848
+ }
849
+
850
+ // Slide is accessible
851
+ accessibilityMap.set(slide.id, { allowed: true, message: null });
852
+ });
853
+
854
+ return accessibilityMap;
855
+ }
856
+
857
+ /**
858
+ * Manually triggers a sync of the navigation state with the current data.
859
+ * This can be used after bulk updates to the state or slides configuration.
860
+ */
861
+ export function sync() {
862
+ _requireInitialized();
863
+
864
+ const currentIndex = NavigationState.getCurrentSlideIndex();
865
+ const currentSlide = slides[currentIndex];
866
+
867
+ // Re-evaluate all slides' accessibility and update the UI accordingly
868
+ const accessibilityMap = checkAllSlidesAccessibility();
869
+ for (const [slideId, accessCheck] of accessibilityMap.entries()) {
870
+ if (accessCheck.allowed) {
871
+ NavigationUI.markAsUnlocked(slideId);
872
+ } else {
873
+ NavigationUI.markAsLocked(slideId);
874
+ }
875
+ }
876
+
877
+ // Update section locked states based on child accessibility
878
+ NavigationUI.updateSectionStates(accessibilityMap);
879
+
880
+ // Determine and update the 'visited' status for all slides
881
+ const visitedSlides = NavigationState.getVisitedSlides();
882
+ slides.forEach(slide => {
883
+ const hasBeenVisited = visitedSlides.includes(slide.id);
884
+
885
+ if (slide.type === 'assessment') {
886
+ const config = assessmentConfigs.get(slide.assessmentId);
887
+ const requirements = config?.completionRequirements;
888
+ let requirementsMet = false;
889
+
890
+ // Only check requirements if they are defined in the assessment's config
891
+ if (requirements) {
892
+ requirementsMet = AssessmentManager.meetsCompletionRequirements(slide.assessmentId, requirements);
893
+ }
894
+
895
+ // Mark as "visited" (i.e., show checkmark) only if visited AND requirements are met.
896
+ // If an assessment has no requirements, it cannot get a checkmark.
897
+ if (hasBeenVisited && requirementsMet) {
898
+ NavigationUI.markAsVisited(slide.id);
899
+ } else {
900
+ NavigationUI.markAsUnvisited(slide.id);
901
+ }
902
+ } else {
903
+ // For regular slides, mark as visited if simply visited
904
+ if (hasBeenVisited) {
905
+ NavigationUI.markAsVisited(slide.id);
906
+ } else {
907
+ NavigationUI.markAsUnvisited(slide.id);
908
+ }
909
+ }
910
+ });
911
+
912
+ // Check both if we can leave the current slide and if the next slide is accessible
913
+ const canNavigateFrom = validateNavigationFrom(currentSlide, assessmentConfigs);
914
+ const nextInfo = _getNextIncludedSlideInfo(currentIndex);
915
+ const isNextAccessible = nextInfo.accessCheck;
916
+
917
+ // Check engagement requirements (with dev mode bypass)
918
+ let engagementComplete = true;
919
+ let engagementProgress = null;
920
+
921
+ if (!shouldBypassEngagement()) {
922
+ const engagementEvaluation = engagementManager.evaluateRequirements(currentSlide.id);
923
+ engagementComplete = engagementEvaluation.complete;
924
+ engagementProgress = engagementEvaluation.progress;
925
+ }
926
+
927
+ const isFirstSlide = currentIndex === 0;
928
+ const isLastSlide = nextInfo.slide === null;
929
+ const nextBlocked = !engagementComplete || !canNavigateFrom.allowed || !isNextAccessible.allowed;
930
+ const nextBlockedMessage = !engagementComplete
931
+ ? engagementProgress?.tooltip
932
+ : (canNavigateFrom.message || isNextAccessible.message);
933
+
934
+ // Update navigation button states with blocking information
935
+ NavigationUI.updateNavButtonState({
936
+ isFirstSlide,
937
+ isLastSlide,
938
+ nextBlocked,
939
+ nextBlockedMessage,
940
+ engagementProgress: engagementProgress?.percentage ?? null,
941
+ });
942
+
943
+ // Update header progress indicator
944
+ const sequentialSlides = slides.filter(s => isSlideInSequence(s, stateManager, assessmentConfigs));
945
+ const currentSequentialIndex = sequentialSlides.findIndex(s => s.id === currentSlide.id);
946
+ const visitedCount = NavigationState.getVisitedSlides().filter(id => sequentialSlides.some(s => s.id === id)).length;
947
+ NavigationUI.updateHeaderProgress(currentSequentialIndex >= 0 ? currentSequentialIndex : currentIndex, sequentialSlides.length, visitedCount);
948
+ }
949
+
950
+ /**
951
+ * Moves to the next sequential slide if available.
952
+ * Checks for navigation.controls.nextTarget or exitTarget overrides before using sequential navigation.
953
+ */
954
+ export async function goToNextAvailableSlide() {
955
+ _requireInitialized();
956
+
957
+ if (navigationLocked) {
958
+ throw _createNavigationError(
959
+ 'goToNextAvailableSlide',
960
+ 'Navigation is locked. Course is in exit process.',
961
+ {}
962
+ );
963
+ }
964
+
965
+ const currentIndex = NavigationState.getCurrentSlideIndex();
966
+ const currentSlide = slides[currentIndex];
967
+
968
+ // Check engagement requirements (with dev mode bypass)
969
+ if (!shouldBypassEngagement()) {
970
+ const evaluation = engagementManager.evaluateRequirements(currentSlide.id);
971
+
972
+ if (!evaluation.complete) {
973
+ // Don't show notification - the tooltip on the disabled button already shows the message
974
+ eventBus.emit('navigation:blocked', {
975
+ reason: 'engagement_incomplete',
976
+ slideId: currentSlide.id,
977
+ unmetRequirements: evaluation.unmetRequirements
978
+ });
979
+ return;
980
+ }
981
+ }
982
+
983
+ const navigationCheck = validateNavigationFrom(currentSlide, assessmentConfigs);
984
+ if (!navigationCheck.allowed) {
985
+ AppActions.showNotification(navigationCheck.message, 'warning', 3000);
986
+ return;
987
+ }
988
+
989
+ // Check for custom navigation targets (nextTarget or exitTarget)
990
+ const controls = currentSlide.navigation?.controls;
991
+ const targetSlideId = controls?.nextTarget || controls?.exitTarget;
992
+
993
+ if (targetSlideId) {
994
+ // Custom target specified - navigate directly to it
995
+ await goToSlide(targetSlideId);
996
+ return;
997
+ }
998
+
999
+ // Default sequential navigation
1000
+ const { slide } = _getNextIncludedSlideInfo(currentIndex);
1001
+ if (!slide) {
1002
+ return; // Already at the end of the active sequence
1003
+ }
1004
+
1005
+ await goToSlide(slide.id);
1006
+ }
1007
+
1008
+ /**
1009
+ * Moves to the previous sequential slide if available.
1010
+ * Checks for navigation.controls.previousTarget override before using sequential navigation.
1011
+ */
1012
+ export async function goToPreviousAvailableSlide() {
1013
+ _requireInitialized();
1014
+
1015
+ if (navigationLocked) {
1016
+ throw _createNavigationError(
1017
+ 'goToPreviousAvailableSlide',
1018
+ 'Navigation is locked. Course is in exit process.',
1019
+ {}
1020
+ );
1021
+ }
1022
+
1023
+ const currentIndex = NavigationState.getCurrentSlideIndex();
1024
+ const currentSlide = slides[currentIndex];
1025
+
1026
+ // Check for custom navigation target (previousTarget)
1027
+ const controls = currentSlide.navigation?.controls;
1028
+ const targetSlideId = controls?.previousTarget;
1029
+
1030
+ if (targetSlideId) {
1031
+ // Custom target specified - navigate directly to it
1032
+ await goToSlide(targetSlideId);
1033
+ return;
1034
+ }
1035
+
1036
+ // Default sequential navigation
1037
+ const { slide } = _getPreviousIncludedSlideInfo(currentIndex);
1038
+ if (!slide) {
1039
+ return;
1040
+ }
1041
+
1042
+ await goToSlide(slide.id);
1043
+ }
1044
+
1045
+ /**
1046
+ * Returns data about the next slide in the active sequence relative to the current slide.
1047
+ * @returns {{index: number|null, slide: object|null, accessCheck: {allowed: boolean, message: string|null}}}
1048
+ */
1049
+ export function getNextSequentialSlideInfo() {
1050
+ _requireInitialized();
1051
+
1052
+ const currentIndex = NavigationState.getCurrentSlideIndex();
1053
+ return _getNextIncludedSlideInfo(currentIndex);
1054
+ }
1055
+
1056
+ /**
1057
+ * Returns data about the previous slide in the active sequence relative to the current slide.
1058
+ * @returns {{index: number|null, slide: object|null, accessCheck: {allowed: boolean, message: string|null}}}
1059
+ */
1060
+ export function getPreviousSequentialSlideInfo() {
1061
+ _requireInitialized();
1062
+
1063
+ const currentIndex = NavigationState.getCurrentSlideIndex();
1064
+ return _getPreviousIncludedSlideInfo(currentIndex);
1065
+ }
1066
+
1067
+
1068
+ /**
1069
+ * Gets the list of all slides in the course.
1070
+ * @returns {object[]} The array of course slides.
1071
+ */
1072
+ export function getAllSlides() {
1073
+ _requireInitialized();
1074
+ return slides;
1075
+ }
1076
+
1077
+ /**
1078
+ * Gets the current slide object.
1079
+ * @returns {object|null} The current slide, or null if not found.
1080
+ */
1081
+ export function getCurrentSlide() {
1082
+ _requireInitialized();
1083
+ const currentIndex = NavigationState.getCurrentSlideIndex();
1084
+ return slides[currentIndex] || null;
1085
+ }
1086
+
1087
+ /**
1088
+ * Gets the ID of the current slide.
1089
+ * @returns {string|null} The ID of the current slide, or null if not found.
1090
+ */
1091
+ export function getCurrentSlideId() {
1092
+ _requireInitialized();
1093
+ const currentSlide = getCurrentSlide();
1094
+ return currentSlide ? currentSlide.id : null;
1095
+ }
1096
+
1097
+ /**
1098
+ * Gets the menu tree structure for the course.
1099
+ * @returns {object[]} The hierarchical menu tree array.
1100
+ */
1101
+ export function getMenuTree() {
1102
+ _requireInitialized();
1103
+ return menuTree;
1104
+ }
1105
+
1106
+ /**
1107
+ * Checks if the user is currently on the last slide.
1108
+ * @returns {boolean} True if on the last slide, false otherwise.
1109
+ */
1110
+ export function isOnLastSlide() {
1111
+ _requireInitialized();
1112
+ const nextInfo = getNextSequentialSlideInfo();
1113
+ return nextInfo.slide === null;
1114
+ }
1115
+
1116
+ /**
1117
+ * @module NavigationActions
1118
+ * This module manages the navigation actions within the course player,
1119
+ * handling user interactions and coordinating between the application state and the UI.
1120
+ *
1121
+ * @example
1122
+ * import { NavigationActions } from 'path/to/NavigationActions';
1123
+ *
1124
+ * // Initialize the navigation actions with course data
1125
+ * NavigationActions.init(courseSlides, viewManagerInstance, courseMenuTree);
1126
+ *
1127
+ * // Manually navigate to a specific slide
1128
+ * NavigationActions.goToSlide('welcome-slide');
1129
+ *
1130
+ * // Reset the navigation state (e.g., on course restart)
1131
+ * NavigationActions.resetNavigation();
1132
+ */