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,1303 @@
1
+ /**
2
+ * stub-player/config-panel.js - Config panel component
3
+ *
4
+ * Generates the config panel HTML for viewing and editing course configuration.
5
+ */
6
+
7
+ /**
8
+ * Generate config panel HTML
9
+ * Note: Content is populated dynamically by JS, this creates the container/tabs
10
+ */
11
+ export function generateConfigPanel() {
12
+ return `
13
+ <div id="stub-player-config-panel">
14
+ <div id="stub-player-config-panel-header">
15
+ <h3>📋 Course Config</h3>
16
+ <button id="stub-player-config-panel-close">&times;</button>
17
+ </div>
18
+ <div id="stub-player-config-tabs">
19
+ <button class="active" data-tab="course">Course</button>
20
+ <button data-tab="slide">Slide</button>
21
+ <button data-tab="objectives">Objectives</button>
22
+ <button data-tab="engagement">Engagement</button>
23
+ <button data-tab="raw">Raw</button>
24
+ </div>
25
+ <div id="stub-player-config-body">
26
+ <div class="config-loading">Loading config...</div>
27
+ </div>
28
+ </div>
29
+ `;
30
+ }
31
+
32
+ /**
33
+ * Initialize Client-Side Handlers
34
+ */
35
+ import { escapeHtml } from './edit-utils.js';
36
+
37
+ export function createConfigPanelHandlers(context) {
38
+ const { getCmiData } = context;
39
+
40
+ let configData = null;
41
+ let currentSlideConfig = null;
42
+ let currentConfigTab = 'course';
43
+
44
+ const configBody = document.getElementById('stub-player-config-body');
45
+
46
+ // Tab switching
47
+ const tabs = document.querySelectorAll('#stub-player-config-tabs button');
48
+ tabs.forEach(tab => {
49
+ tab.addEventListener('click', () => {
50
+ tabs.forEach(t => t.classList.remove('active'));
51
+ tab.classList.add('active');
52
+
53
+ currentConfigTab = tab.dataset.tab;
54
+ renderConfigTab();
55
+ });
56
+ });
57
+
58
+ // Close button
59
+ document.getElementById('stub-player-config-panel-close')?.addEventListener('click', () => {
60
+ document.getElementById('stub-player-config-panel').classList.remove('visible');
61
+ });
62
+
63
+ async function loadConfig() {
64
+ if (!configBody) return;
65
+
66
+ configBody.innerHTML = '<div class="config-loading">Loading config...</div>';
67
+
68
+ try {
69
+ const response = await fetch('/__config');
70
+ if (response.ok) {
71
+ configData = await response.json();
72
+ renderConfigTab();
73
+ } else {
74
+ configBody.innerHTML = '<div class="config-error">Failed to load config</div>';
75
+ }
76
+ } catch (err) {
77
+ configBody.innerHTML = '<div class="config-error">Error: ' + err.message + '</div>';
78
+ }
79
+ }
80
+
81
+ function renderConfigTab() {
82
+ if (!configData || !configBody) return;
83
+
84
+ if (currentConfigTab === 'course') {
85
+ renderCourseTab();
86
+ } else if (currentConfigTab === 'slide') {
87
+ renderSlideTab();
88
+ } else if (currentConfigTab === 'objectives') {
89
+ renderObjectivesTab();
90
+ } else if (currentConfigTab === 'engagement') {
91
+ renderEngagementTab();
92
+ } else if (currentConfigTab === 'raw') {
93
+ renderRawTab();
94
+ }
95
+ }
96
+
97
+ async function renderCourseTab() {
98
+ const layouts = ['article', 'traditional', 'focused', 'presentation', 'canvas'];
99
+ const widths = ['narrow', 'medium', 'wide', 'full'];
100
+ const formats = ['cmi5', 'scorm2004', 'scorm1.2'];
101
+
102
+ // Fetch theme data
103
+ let themeTokens = [];
104
+ try {
105
+ const themeResponse = await fetch('/__theme');
106
+ if (themeResponse.ok) {
107
+ const themeData = await themeResponse.json();
108
+ themeTokens = themeData.tokens || [];
109
+ }
110
+ } catch (e) {
111
+ console.warn('Could not load theme data:', e);
112
+ }
113
+
114
+ configBody.innerHTML = `
115
+ <div class="config-section">
116
+ <div class="config-section-header">Course Metadata</div>
117
+ <div class="config-row">
118
+ <span class="config-label">Title</span>
119
+ <input type="text" class="config-input" data-path="metadata.title" value="${escapeHtml(configData.metadata?.title || '')}" placeholder="Course Title">
120
+ </div>
121
+ <div class="config-row">
122
+ <span class="config-label">Description</span>
123
+ <input type="text" class="config-input" data-path="metadata.description" value="${escapeHtml(configData.metadata?.description || '')}" placeholder="Course description">
124
+ </div>
125
+ <div class="config-row">
126
+ <span class="config-label">Version</span>
127
+ <input type="text" class="config-input" data-path="metadata.version" value="${escapeHtml(configData.metadata?.version || '')}" placeholder="1.0.0">
128
+ </div>
129
+ <div class="config-row">
130
+ <span class="config-label">Author</span>
131
+ <input type="text" class="config-input" data-path="metadata.author" value="${escapeHtml(configData.metadata?.author || '')}" placeholder="Author name">
132
+ </div>
133
+ <div class="config-row">
134
+ <span class="config-label">Language</span>
135
+ <input type="text" class="config-input" data-path="metadata.language" value="${escapeHtml(configData.metadata?.language || '')}" placeholder="en">
136
+ </div>
137
+ <div class="config-row">
138
+ <span class="config-label">Total Slides</span>
139
+ <span class="config-value">${configData.slideCount || 0}</span>
140
+ </div>
141
+ <div class="config-row">
142
+ <span class="config-label">Output Format</span>
143
+ <select data-path="format">
144
+ ${formats.map(f => `<option value="${f}" ${configData.format === f ? 'selected' : ''}>${f}</option>`).join('')}
145
+ </select>
146
+ </div>
147
+ </div>
148
+
149
+ <div class="config-divider"></div>
150
+
151
+ <div class="config-section">
152
+ <div class="config-section-header">Layout</div>
153
+ <div class="config-row">
154
+ <span class="config-label">Course Layout</span>
155
+ <select data-path="layout">
156
+ ${layouts.map(l => `<option value="${l}" ${configData.layout === l ? 'selected' : ''}>${l}</option>`).join('')}
157
+ </select>
158
+ </div>
159
+ <div class="config-row">
160
+ <span class="config-label">Content Width</span>
161
+ <select data-path="slideDefaults.contentWidth" ${['focused', 'presentation', 'canvas'].includes(configData.layout) ? 'disabled' : ''}>
162
+ ${widths.map(w => `<option value="${w}" ${configData.slideDefaults?.contentWidth === w ? 'selected' : ''}>${w}</option>`).join('')}
163
+ </select>
164
+ ${['focused', 'presentation', 'canvas'].includes(configData.layout) ? `<span class="config-override-hint" title="${configData.layout === 'focused' ? 'Focused layout uses 1000px width' : configData.layout === 'canvas' ? 'Canvas layout has no framework chrome' : 'Presentation layout uses full viewport'}">override</span>` : ''}
165
+ </div>
166
+ </div>
167
+
168
+ <div class="config-section">
169
+ <div class="config-section-header">Navigation</div>
170
+ <div class="config-row">
171
+ <span class="config-label">Sidebar Enabled</span>
172
+ <div class="config-toggle ${configData.navigation?.sidebar?.enabled ? 'on' : ''}" data-path="navigation.sidebar.enabled"></div>
173
+ </div>
174
+ ${configData.navigation?.sidebar?.enabled ? `
175
+ <div class="config-row">
176
+ <span class="config-label">Sidebar Position</span>
177
+ <select data-path="navigation.sidebar.position">
178
+ <option value="left" ${configData.navigation?.sidebar?.position === 'left' ? 'selected' : ''}>left</option>
179
+ <option value="right" ${configData.navigation?.sidebar?.position === 'right' ? 'selected' : ''}>right</option>
180
+ </select>
181
+ </div>
182
+ <div class="config-row">
183
+ <span class="config-label">Sidebar Width</span>
184
+ <input type="text" class="config-input" data-path="navigation.sidebar.width" value="${configData.navigation?.sidebar?.width || '280px'}" placeholder="280px">
185
+ </div>
186
+ <div class="config-row">
187
+ <span class="config-label">Sidebar Collapsible</span>
188
+ <div class="config-toggle ${configData.navigation?.sidebar?.collapsible ? 'on' : ''}" data-path="navigation.sidebar.collapsible"></div>
189
+ </div>
190
+ <div class="config-row">
191
+ <span class="config-label">Sidebar Default Collapsed</span>
192
+ <div class="config-toggle ${configData.navigation?.sidebar?.defaultCollapsed ? 'on' : ''}" data-path="navigation.sidebar.defaultCollapsed"></div>
193
+ </div>
194
+ <div class="config-row">
195
+ <span class="config-label">Sidebar Show Progress</span>
196
+ <div class="config-toggle ${configData.navigation?.sidebar?.showProgress ? 'on' : ''}" data-path="navigation.sidebar.showProgress"></div>
197
+ </div>
198
+ ` : ''}
199
+ <div class="config-row">
200
+ <span class="config-label">Breadcrumbs Enabled</span>
201
+ <div class="config-toggle ${configData.navigation?.breadcrumbs?.enabled ? 'on' : ''}" data-path="navigation.breadcrumbs.enabled"></div>
202
+ </div>
203
+ </div>
204
+
205
+ <div class="config-divider"></div>
206
+
207
+ <div class="config-section">
208
+ <div class="config-section-header">Accessibility</div>
209
+ <div class="config-row">
210
+ <span class="config-label">Dark Mode</span>
211
+ <div class="config-toggle ${configData.features?.accessibility?.darkMode ? 'on' : ''}" data-path="features.accessibility.darkMode"></div>
212
+ </div>
213
+ <div class="config-row">
214
+ <span class="config-label">Font Size Controls</span>
215
+ <div class="config-toggle ${configData.features?.accessibility?.fontSize ? 'on' : ''}" data-path="features.accessibility.fontSize"></div>
216
+ </div>
217
+ <div class="config-row">
218
+ <span class="config-label">High Contrast</span>
219
+ <div class="config-toggle ${configData.features?.accessibility?.highContrast ? 'on' : ''}" data-path="features.accessibility.highContrast"></div>
220
+ </div>
221
+ <div class="config-row">
222
+ <span class="config-label">Reduced Motion</span>
223
+ <div class="config-toggle ${configData.features?.accessibility?.reducedMotion ? 'on' : ''}" data-path="features.accessibility.reducedMotion"></div>
224
+ </div>
225
+ <div class="config-row">
226
+ <span class="config-label">Keyboard Shortcuts</span>
227
+ <div class="config-toggle ${configData.features?.accessibility?.keyboardShortcuts ? 'on' : ''}" data-path="features.accessibility.keyboardShortcuts"></div>
228
+ </div>
229
+ </div>
230
+
231
+ <div class="config-section">
232
+ <div class="config-section-header">Completion</div>
233
+ <div class="config-row">
234
+ <span class="config-label">Prompt for Comments</span>
235
+ <div class="config-toggle ${configData.completion?.promptForComments ? 'on' : ''}" data-path="completion.promptForComments"></div>
236
+ </div>
237
+ <div class="config-row">
238
+ <span class="config-label">Prompt for Rating</span>
239
+ <div class="config-toggle ${configData.completion?.promptForRating ? 'on' : ''}" data-path="completion.promptForRating"></div>
240
+ </div>
241
+ </div>
242
+
243
+ <div class="config-section">
244
+ <div class="config-section-header">Scoring</div>
245
+ <div class="config-row">
246
+ <span class="config-label">Scoring Type</span>
247
+ <select data-path="scoring.type">
248
+ <option value="" ${!configData.scoring?.type ? 'selected' : ''}>(disabled)</option>
249
+ <option value="average" ${configData.scoring?.type === 'average' ? 'selected' : ''}>average</option>
250
+ <option value="weighted" ${configData.scoring?.type === 'weighted' ? 'selected' : ''}>weighted</option>
251
+ <option value="maximum" ${configData.scoring?.type === 'maximum' ? 'selected' : ''}>maximum</option>
252
+ <option value="custom" ${configData.scoring?.type === 'custom' ? 'selected' : ''}>custom</option>
253
+ </select>
254
+ </div>
255
+ </div>
256
+
257
+ <div class="config-divider"></div>
258
+
259
+ <div class="config-section">
260
+ <div class="config-section-header">Support</div>
261
+ <div class="config-row">
262
+ <span class="config-label">Email</span>
263
+ <input type="text" class="config-input" data-path="support.email" value="${escapeHtml(configData.support?.email || '')}" placeholder="support@example.com">
264
+ </div>
265
+ <div class="config-row">
266
+ <span class="config-label">Phone</span>
267
+ <input type="text" class="config-input" data-path="support.phone" value="${escapeHtml(configData.support?.phone || '')}" placeholder="+1-800-555-0100">
268
+ </div>
269
+ </div>
270
+
271
+ <div class="config-divider"></div>
272
+
273
+ <div class="config-section">
274
+ <div class="config-section-header">Development</div>
275
+ <div class="config-row">
276
+ <span class="config-label">Show Slide Indicator</span>
277
+ <div class="config-toggle ${configData.environment?.development?.showSlideIndicator ? 'on' : ''}" data-path="environment.development.showSlideIndicator"></div>
278
+ </div>
279
+ <div class="config-row">
280
+ <span class="config-label">Disable Beforeunload Guard</span>
281
+ <div class="config-toggle ${configData.environment?.disableBeforeUnloadGuard ? 'on' : ''}" data-path="environment.disableBeforeUnloadGuard"></div>
282
+ </div>
283
+ </div>
284
+
285
+ <div class="config-divider"></div>
286
+
287
+ <div class="config-section">
288
+ <div class="config-section-header">Theme Colors</div>
289
+ <div class="config-hint">Override palette colors in theme.css. Changes apply after reload.</div>
290
+ ${themeTokens.map(t => `
291
+ <div class="config-row config-color-row" data-token="${t.name}">
292
+ <span class="config-label">${escapeHtml(t.label)}</span>
293
+ ${t.override ? '<span class="config-override-badge">override</span>' : ''}
294
+ <div class="config-color-controls">
295
+ <input type="color" class="config-color-picker" data-token="${t.name}" value="${t.override || t.default || '#808080'}">
296
+ <input type="text" class="config-color-hex" data-token="${t.name}" value="${t.override || t.default || ''}" placeholder="${t.default || ''}">
297
+ ${t.override ? `<button class="config-color-reset" data-token="${t.name}" title="Reset to default">×</button>` : ''}
298
+ </div>
299
+ </div>
300
+ `).join('')}
301
+ </div>
302
+ `;
303
+
304
+ // Bind color picker changes
305
+ configBody.querySelectorAll('.config-color-picker').forEach(picker => {
306
+ picker.addEventListener('input', function () {
307
+ const token = this.dataset.token;
308
+ const hexInput = configBody.querySelector(`.config-color-hex[data-token="${token}"]`);
309
+ if (hexInput) hexInput.value = this.value;
310
+ });
311
+ picker.addEventListener('change', async function () {
312
+ const token = this.dataset.token;
313
+ await saveThemeValue(token, this.value);
314
+ });
315
+ });
316
+
317
+ // Bind hex input changes
318
+ configBody.querySelectorAll('.config-color-hex').forEach(input => {
319
+ let timeout;
320
+ input.addEventListener('input', function () {
321
+ const token = this.dataset.token;
322
+ const picker = configBody.querySelector(`.config-color-picker[data-token="${token}"]`);
323
+ // Only update picker if valid hex
324
+ if (/^#[0-9A-Fa-f]{6}$/.test(this.value)) {
325
+ if (picker) picker.value = this.value;
326
+ }
327
+ clearTimeout(timeout);
328
+ timeout = setTimeout(async () => {
329
+ if (/^#[0-9A-Fa-f]{6}$/.test(this.value)) {
330
+ await saveThemeValue(token, this.value);
331
+ }
332
+ }, 500);
333
+ });
334
+ });
335
+
336
+ // Bind reset buttons
337
+ configBody.querySelectorAll('.config-color-reset').forEach(btn => {
338
+ btn.addEventListener('click', async function () {
339
+ const token = this.dataset.token;
340
+ await saveThemeValue(token, null);
341
+ // Refresh the tab to show updated state
342
+ await renderCourseTab();
343
+ });
344
+ });
345
+
346
+ // Bind select changes
347
+ configBody.querySelectorAll('select[data-path]').forEach(sel => {
348
+ sel.addEventListener('change', async function () {
349
+ const path = this.dataset.path;
350
+ const value = this.value;
351
+ updateConfigDataLocally(path, value);
352
+ await saveConfigValue(path, value);
353
+ });
354
+ });
355
+
356
+ // Bind toggle clicks
357
+ configBody.querySelectorAll('.config-toggle[data-path]').forEach(toggle => {
358
+ toggle.addEventListener('click', async function () {
359
+ const isOn = this.classList.contains('on');
360
+ const newValue = !isOn;
361
+ const path = this.dataset.path;
362
+ this.classList.toggle('on');
363
+ updateConfigDataLocally(path, newValue);
364
+ await saveConfigValue(path, newValue);
365
+ });
366
+ });
367
+
368
+ // Bind input changes (with debounce)
369
+ configBody.querySelectorAll('.config-input[data-path]').forEach(input => {
370
+ let timeout;
371
+ input.addEventListener('input', function () {
372
+ clearTimeout(timeout);
373
+ const path = this.dataset.path;
374
+ const value = this.value;
375
+ timeout = setTimeout(async () => {
376
+ updateConfigDataLocally(path, value);
377
+ await saveConfigValue(path, value);
378
+ }, 500);
379
+ });
380
+ });
381
+ }
382
+
383
+ /**
384
+ * Update configData locally to keep it in sync with form state.
385
+ * This prevents stale values when the panel re-renders.
386
+ */
387
+ function updateConfigDataLocally(path, value) {
388
+ if (!configData) return;
389
+
390
+ const parts = path.split('.');
391
+ let current = configData;
392
+
393
+ for (let i = 0; i < parts.length - 1; i++) {
394
+ const key = parts[i];
395
+ if (current[key] === undefined) {
396
+ current[key] = {};
397
+ }
398
+ current = current[key];
399
+ }
400
+
401
+ current[parts[parts.length - 1]] = value;
402
+ }
403
+
404
+ async function renderSlideTab() {
405
+ const cmiData = getCmiData ? getCmiData() : {};
406
+ const currentSlideId = cmiData['cmi.location'];
407
+
408
+ if (!currentSlideId) {
409
+ configBody.innerHTML = `
410
+ <div class="config-slide-info">
411
+ <div class="slide-title">No Slide Selected</div>
412
+ <div class="slide-id">(none)</div>
413
+ </div>
414
+ <div class="config-section">
415
+ <p style="color: #6b7280; font-size: 12px; margin: 8px 0;">
416
+ Navigate to a slide to see its configuration here.
417
+ </p>
418
+ </div>
419
+ `;
420
+ return;
421
+ }
422
+
423
+ // Show loading state
424
+ configBody.innerHTML = '<div class="config-loading">Loading slide config...</div>';
425
+
426
+ try {
427
+ const response = await fetch('/__slide-config/' + encodeURIComponent(currentSlideId));
428
+ if (!response.ok) {
429
+ throw new Error('Slide not found');
430
+ }
431
+ const slideConfig = await response.json();
432
+ currentSlideConfig = slideConfig;
433
+
434
+ // Build the slide tab HTML with editable fields
435
+ let html = '';
436
+
437
+ // === Slide Identity ===
438
+ html += `
439
+ <div class="config-slide-info">
440
+ <div class="slide-title">${escapeHtml(slideConfig.title || slideConfig.id)}</div>
441
+ <div class="slide-id">${escapeHtml(slideConfig.id)}</div>
442
+ </div>
443
+
444
+ <div class="config-section">
445
+ <div class="config-section-header">Identity</div>
446
+ <div class="config-row">
447
+ <span class="config-label">Title</span>
448
+ <input type="text" class="config-input slide-config-input" data-slide-path="title" value="${escapeHtml(slideConfig.title || '')}" placeholder="Slide title">
449
+ </div>
450
+ <div class="config-row">
451
+ <span class="config-label">Type</span>
452
+ <span class="config-value config-badge config-badge-${slideConfig.type || 'slide'}">${escapeHtml(slideConfig.type || 'slide')}</span>
453
+ </div>
454
+ <div class="config-row">
455
+ <span class="config-label">Component</span>
456
+ <span class="config-value config-path">${escapeHtml(slideConfig.component || '(none)')}</span>
457
+ </div>
458
+ </div>
459
+ `;
460
+
461
+ // === Menu Configuration ===
462
+ const menu = slideConfig.menu || {};
463
+ html += `
464
+ <div class="config-divider"></div>
465
+ <div class="config-section">
466
+ <div class="config-section-header">Menu</div>
467
+ <div class="config-row">
468
+ <span class="config-label">Label</span>
469
+ <input type="text" class="config-input slide-config-input" data-slide-path="menu.label" value="${escapeHtml(menu.label || slideConfig.title || slideConfig.id)}" placeholder="Menu label">
470
+ </div>
471
+ <div class="config-row">
472
+ <span class="config-label">Icon</span>
473
+ <input type="text" class="config-input slide-config-input" data-slide-path="menu.icon" value="${escapeHtml(menu.icon || '')}" placeholder="Icon name (e.g., book-open)">
474
+ </div>
475
+ <div class="config-row">
476
+ <span class="config-label">Hidden</span>
477
+ <div class="config-toggle slide-config-toggle ${menu.hidden ? 'on' : ''}" data-slide-path="menu.hidden"></div>
478
+ </div>
479
+ </div>
480
+ `;
481
+
482
+ // === Audio Configuration ===
483
+ const audio = slideConfig.audio || {};
484
+ const hasAudio = !!slideConfig.audio;
485
+ html += `
486
+ <div class="config-divider"></div>
487
+ <div class="config-section">
488
+ <div class="config-section-header">Audio</div>
489
+ ${hasAudio ? `
490
+ <div class="config-row">
491
+ <span class="config-label">Source</span>
492
+ <input type="text" class="config-input slide-config-input" data-slide-path="audio.src" value="${escapeHtml(audio.src || '')}" placeholder="Audio source path">
493
+ </div>
494
+ <div class="config-row">
495
+ <span class="config-label">Autoplay</span>
496
+ <div class="config-toggle slide-config-toggle ${audio.autoplay ? 'on' : ''}" data-slide-path="audio.autoplay"></div>
497
+ </div>
498
+ <div class="config-row">
499
+ <span class="config-label">Completion Threshold</span>
500
+ <input type="number" class="config-input slide-config-input" data-slide-path="audio.completionThreshold" value="${audio.completionThreshold !== undefined ? audio.completionThreshold : 0.95}" min="0" max="1" step="0.05" style="width: 80px;">
501
+ </div>
502
+ ` : `
503
+ <div class="config-row">
504
+ <span class="config-label" style="color: #6b7280; font-style: italic;">No audio configured</span>
505
+ </div>
506
+ `}
507
+ </div>
508
+ `;
509
+
510
+ // === Engagement Configuration ===
511
+ const engagement = slideConfig.engagement || {};
512
+ html += `
513
+ <div class="config-divider"></div>
514
+ <div class="config-section">
515
+ <div class="config-section-header">Engagement</div>
516
+ <div class="config-row">
517
+ <span class="config-label">Required</span>
518
+ <div class="config-toggle slide-config-toggle ${engagement.required ? 'on' : ''}" data-slide-path="engagement.required"></div>
519
+ </div>
520
+ `;
521
+
522
+ if (engagement.required) {
523
+ html += `
524
+ <div class="config-row">
525
+ <span class="config-label">Mode</span>
526
+ <select class="slide-config-select" data-slide-path="engagement.mode">
527
+ <option value="all" ${(engagement.mode || 'all') === 'all' ? 'selected' : ''}>all</option>
528
+ <option value="any" ${engagement.mode === 'any' ? 'selected' : ''}>any</option>
529
+ </select>
530
+ </div>
531
+ <div class="config-row">
532
+ <span class="config-label">Show Indicator</span>
533
+ <div class="config-toggle slide-config-toggle ${engagement.showIndicator !== false ? 'on' : ''}" data-slide-path="engagement.showIndicator"></div>
534
+ </div>
535
+ `;
536
+
537
+ const reqCount = engagement.requirements?.length || 0;
538
+ if (reqCount > 0) {
539
+ html += `
540
+ <div class="config-row">
541
+ <span class="config-label">Requirements</span>
542
+ <span class="config-value" style="color: #f18701; cursor: pointer;" onclick="document.querySelector('#stub-player-config-tabs button[data-tab=engagement]').click()">${reqCount} configured → Edit</span>
543
+ </div>
544
+ `;
545
+ }
546
+ }
547
+
548
+ html += '</div>';
549
+
550
+ // === Navigation Configuration ===
551
+ const nav = slideConfig.navigation || {};
552
+ const controls = nav.controls || {};
553
+ html += `
554
+ <div class="config-divider"></div>
555
+ <div class="config-section">
556
+ <div class="config-section-header">Navigation</div>
557
+ <div class="config-row">
558
+ <span class="config-label">Sequential</span>
559
+ <div class="config-toggle slide-config-toggle ${nav.sequential !== false ? 'on' : ''}" data-slide-path="navigation.sequential"></div>
560
+ </div>
561
+ <div class="config-row">
562
+ <span class="config-label">Show Previous</span>
563
+ <div class="config-toggle slide-config-toggle ${controls.showPrevious !== false ? 'on' : ''}" data-slide-path="navigation.controls.showPrevious"></div>
564
+ </div>
565
+ <div class="config-row">
566
+ <span class="config-label">Show Next</span>
567
+ <div class="config-toggle slide-config-toggle ${controls.showNext !== false ? 'on' : ''}" data-slide-path="navigation.controls.showNext"></div>
568
+ </div>
569
+ `;
570
+
571
+ if (controls.exitTarget) {
572
+ html += `
573
+ <div class="config-row">
574
+ <span class="config-label">Exit Target</span>
575
+ <input type="text" class="config-input slide-config-input" data-slide-path="navigation.controls.exitTarget" value="${escapeHtml(controls.exitTarget)}" placeholder="Slide ID">
576
+ </div>
577
+ `;
578
+ }
579
+ if (controls.nextTarget) {
580
+ html += `
581
+ <div class="config-row">
582
+ <span class="config-label">Next Target</span>
583
+ <input type="text" class="config-input slide-config-input" data-slide-path="navigation.controls.nextTarget" value="${escapeHtml(controls.nextTarget)}" placeholder="Slide ID">
584
+ </div>
585
+ `;
586
+ }
587
+ if (controls.previousTarget) {
588
+ html += `
589
+ <div class="config-row">
590
+ <span class="config-label">Previous Target</span>
591
+ <input type="text" class="config-input slide-config-input" data-slide-path="navigation.controls.previousTarget" value="${escapeHtml(controls.previousTarget)}" placeholder="Slide ID">
592
+ </div>
593
+ `;
594
+ }
595
+
596
+ // === Gating Configuration ===
597
+ const gating = nav.gating || {};
598
+ const gatingConditions = gating.conditions || [];
599
+ const slideIds = configData.slideIds || [];
600
+ const objectiveIds = configData.objectiveIds || [];
601
+ const assessmentIds = slideIds.filter(s => s.type === 'assessment');
602
+
603
+ html += `
604
+ <div class="config-divider"></div>
605
+ <div class="config-section">
606
+ <div class="config-section-header">Gating</div>
607
+ <div class="config-row">
608
+ <span class="config-label">Mode</span>
609
+ <select class="slide-config-select" data-slide-path="navigation.gating.mode">
610
+ <option value="">None (no gating)</option>
611
+ <option value="all" ${gating.mode === 'all' ? 'selected' : ''}>All conditions</option>
612
+ <option value="any" ${gating.mode === 'any' ? 'selected' : ''}>Any condition</option>
613
+ </select>
614
+ </div>
615
+ <div class="config-row">
616
+ <span class="config-label">Message</span>
617
+ <input type="text" class="config-input slide-config-input" data-slide-path="navigation.gating.message" value="${escapeHtml(gating.message || '')}" placeholder="Message when gated" style="max-width: 240px;">
618
+ </div>
619
+ `;
620
+
621
+ // Render existing conditions
622
+ if (gatingConditions.length > 0) {
623
+ html += '<div class="gating-conditions-list">';
624
+
625
+ for (let i = 0; i < gatingConditions.length; i++) {
626
+ const cond = gatingConditions[i];
627
+ html += `
628
+ <div class="gating-condition-item" data-condition-index="${i}">
629
+ <div class="config-row gating-condition-type-row">
630
+ <span class="config-label">Type</span>
631
+ <div class="gating-condition-type-controls">
632
+ <select class="gating-condition-type" data-index="${i}">
633
+ <option value="objectiveStatus" ${cond.type === 'objectiveStatus' ? 'selected' : ''}>Objective Status</option>
634
+ <option value="assessmentStatus" ${cond.type === 'assessmentStatus' ? 'selected' : ''}>Assessment Status</option>
635
+ <option value="stateFlag" ${cond.type === 'stateFlag' ? 'selected' : ''}>State Flag</option>
636
+ <option value="timeOnSlide" ${cond.type === 'timeOnSlide' ? 'selected' : ''}>Time on Slide</option>
637
+ </select>
638
+ <button type="button" class="gating-remove-btn" data-index="${i}" aria-label="Remove condition">✕</button>
639
+ </div>
640
+ </div>
641
+ `;
642
+
643
+ // Type-specific fields
644
+ if (cond.type === 'objectiveStatus') {
645
+ html += `
646
+ <div class="config-row" style="margin-bottom: 4px;">
647
+ <span class="config-label" style="font-size: 10px;">Objective</span>
648
+ <select class="gating-condition-field" data-index="${i}" data-field="objectiveId" style="font-size: 11px;">
649
+ <option value="">Select...</option>
650
+ ${objectiveIds.map(o => `<option value="${escapeHtml(o.id)}" ${cond.objectiveId === o.id ? 'selected' : ''}>${escapeHtml(o.id)}</option>`).join('')}
651
+ </select>
652
+ </div>
653
+ <div class="config-row">
654
+ <span class="config-label" style="font-size: 10px;">Completion</span>
655
+ <select class="gating-condition-field" data-index="${i}" data-field="completion_status" style="font-size: 11px;">
656
+ <option value="completed" ${cond.completion_status === 'completed' ? 'selected' : ''}>completed</option>
657
+ <option value="incomplete" ${cond.completion_status === 'incomplete' ? 'selected' : ''}>incomplete</option>
658
+ </select>
659
+ </div>
660
+ `;
661
+ } else if (cond.type === 'assessmentStatus') {
662
+ html += `
663
+ <div class="config-row" style="margin-bottom: 4px;">
664
+ <span class="config-label" style="font-size: 10px;">Assessment</span>
665
+ <select class="gating-condition-field" data-index="${i}" data-field="assessmentId" style="font-size: 11px;">
666
+ <option value="">Select...</option>
667
+ ${assessmentIds.map(a => `<option value="${escapeHtml(a.id)}" ${cond.assessmentId === a.id ? 'selected' : ''}>${escapeHtml(a.title)}</option>`).join('')}
668
+ </select>
669
+ </div>
670
+ <div class="config-row">
671
+ <span class="config-label" style="font-size: 10px;">Requires</span>
672
+ <select class="gating-condition-field" data-index="${i}" data-field="requires" style="font-size: 11px;">
673
+ <option value="passed" ${cond.requires === 'passed' ? 'selected' : ''}>passed</option>
674
+ <option value="failed" ${cond.requires === 'failed' ? 'selected' : ''}>failed</option>
675
+ <option value="attempted" ${cond.requires === 'attempted' ? 'selected' : ''}>attempted</option>
676
+ </select>
677
+ </div>
678
+ `;
679
+ } else if (cond.type === 'stateFlag') {
680
+ html += `
681
+ <div class="config-row">
682
+ <span class="config-label" style="font-size: 10px;">Flag Key</span>
683
+ <input type="text" class="gating-condition-field config-input" data-index="${i}" data-field="key" value="${escapeHtml(cond.key || '')}" placeholder="flag_key" style="font-size: 11px;">
684
+ </div>
685
+ `;
686
+ } else if (cond.type === 'timeOnSlide') {
687
+ html += `
688
+ <div class="config-row" style="margin-bottom: 4px;">
689
+ <span class="config-label" style="font-size: 10px;">Slide</span>
690
+ <select class="gating-condition-field" data-index="${i}" data-field="slideId" style="font-size: 11px;">
691
+ <option value="">Select...</option>
692
+ ${slideIds.map(s => `<option value="${escapeHtml(s.id)}" ${cond.slideId === s.id ? 'selected' : ''}>${escapeHtml(s.title)}</option>`).join('')}
693
+ </select>
694
+ </div>
695
+ <div class="config-row">
696
+ <span class="config-label" style="font-size: 10px;">Min Seconds</span>
697
+ <input type="number" class="gating-condition-field config-input" data-index="${i}" data-field="minSeconds" value="${cond.minSeconds || 30}" min="1" style="font-size: 11px; width: 60px;">
698
+ </div>
699
+ `;
700
+ }
701
+
702
+ html += '</div>';
703
+ }
704
+
705
+ html += '</div>';
706
+ }
707
+
708
+ // Add condition button
709
+ html += `
710
+ <div style="margin-top: 8px;">
711
+ <button type="button" class="gating-add-btn" style="background: #2d5a87; border: none; color: white; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 11px;">+ Add Condition</button>
712
+ </div>
713
+ `;
714
+
715
+ html += '</div>';
716
+
717
+ html += '</div>';
718
+
719
+ configBody.innerHTML = html;
720
+
721
+ // Bind slide config handlers
722
+ bindSlideConfigHandlers(currentSlideId);
723
+
724
+ } catch (err) {
725
+ configBody.innerHTML = `
726
+ <div class="config-slide-info">
727
+ <div class="slide-title">Slide</div>
728
+ <div class="slide-id">${escapeHtml(currentSlideId)}</div>
729
+ </div>
730
+ <div class="config-section">
731
+ <div class="config-error">Error loading slide config: ${escapeHtml(err.message)}</div>
732
+ </div>
733
+ `;
734
+ }
735
+ }
736
+
737
+ function bindSlideConfigHandlers(slideId) {
738
+ // Bind toggle clicks
739
+ configBody.querySelectorAll('.slide-config-toggle[data-slide-path]').forEach(toggle => {
740
+ toggle.addEventListener('click', async function () {
741
+ const isOn = this.classList.contains('on');
742
+ this.classList.toggle('on');
743
+ await saveSlideConfigValue(slideId, this.dataset.slidePath, !isOn);
744
+ });
745
+ });
746
+
747
+ // Bind select changes
748
+ configBody.querySelectorAll('.slide-config-select[data-slide-path]').forEach(sel => {
749
+ sel.addEventListener('change', async function () {
750
+ await saveSlideConfigValue(slideId, this.dataset.slidePath, this.value);
751
+ });
752
+ });
753
+
754
+ // Bind input changes (with debounce)
755
+ configBody.querySelectorAll('.slide-config-input[data-slide-path]').forEach(input => {
756
+ let timeout;
757
+ input.addEventListener('input', function () {
758
+ clearTimeout(timeout);
759
+ timeout = setTimeout(async () => {
760
+ let value = this.value;
761
+ // Convert numbers
762
+ if (this.type === 'number') {
763
+ value = parseFloat(value);
764
+ }
765
+ await saveSlideConfigValue(slideId, this.dataset.slidePath, value);
766
+ }, 500);
767
+ });
768
+ });
769
+
770
+ // === Gating condition handlers ===
771
+
772
+ // Add condition button
773
+ configBody.querySelector('.gating-add-btn')?.addEventListener('click', async () => {
774
+ await addGatingCondition(slideId);
775
+ });
776
+
777
+ // Remove condition button
778
+ configBody.querySelectorAll('.gating-remove-btn[data-index]').forEach(btn => {
779
+ btn.addEventListener('click', async () => {
780
+ const index = parseInt(btn.dataset.index, 10);
781
+ await removeGatingCondition(slideId, index);
782
+ });
783
+ });
784
+
785
+ // Condition type change
786
+ configBody.querySelectorAll('.gating-condition-type[data-index]').forEach(sel => {
787
+ sel.addEventListener('change', async () => {
788
+ const index = parseInt(sel.dataset.index, 10);
789
+ await updateGatingConditionType(slideId, index, sel.value);
790
+ });
791
+ });
792
+
793
+ // Condition field change
794
+ configBody.querySelectorAll('.gating-condition-field[data-index]').forEach(field => {
795
+ field.addEventListener('change', async () => {
796
+ const index = parseInt(field.dataset.index, 10);
797
+ const fieldName = field.dataset.field;
798
+ await updateGatingConditionField(slideId, index, fieldName, field.value);
799
+ });
800
+ });
801
+ }
802
+
803
+ // === Unified write helper ===
804
+ async function writeConfig(target, id, value) {
805
+ try {
806
+ const response = await fetch('/__write', {
807
+ method: 'POST',
808
+ headers: { 'Content-Type': 'application/json' },
809
+ body: JSON.stringify({ target, id, value })
810
+ });
811
+ if (!response.ok) {
812
+ const err = await response.json();
813
+ console.error(`Write error [${target}]:`, err);
814
+ return false;
815
+ }
816
+ return true;
817
+ } catch (err) {
818
+ console.error(`Failed to write [${target}]:`, err);
819
+ return false;
820
+ }
821
+ }
822
+
823
+ // Gating condition management — mutate locally, write full gating object
824
+ async function addGatingCondition(slideId) {
825
+ const gating = currentSlideConfig?.navigation?.gating;
826
+ if (!gating?.conditions) return;
827
+ gating.conditions.push({ type: 'objectiveStatus', objectiveId: '', completion_status: 'completed' });
828
+ if (await writeConfig('gating', slideId, gating)) renderSlideTab();
829
+ }
830
+
831
+ async function removeGatingCondition(slideId, index) {
832
+ const gating = currentSlideConfig?.navigation?.gating;
833
+ if (!gating?.conditions) return;
834
+ gating.conditions.splice(index, 1);
835
+ if (await writeConfig('gating', slideId, gating)) renderSlideTab();
836
+ }
837
+
838
+ async function updateGatingConditionType(slideId, index, newType) {
839
+ const gating = currentSlideConfig?.navigation?.gating;
840
+ if (!gating?.conditions) return;
841
+ const defaults = {
842
+ objectiveStatus: { type: 'objectiveStatus', objectiveId: '', completion_status: 'completed' },
843
+ assessmentStatus: { type: 'assessmentStatus', assessmentId: '', requires: 'passed' },
844
+ slideVisited: { type: 'slideVisited', slideId: '' },
845
+ timeOnSlide: { type: 'timeOnSlide', slideId: '', minSeconds: 30 },
846
+ stateFlag: { type: 'stateFlag', key: '' }
847
+ };
848
+ gating.conditions[index] = defaults[newType] || { type: newType };
849
+ if (await writeConfig('gating', slideId, gating)) renderSlideTab();
850
+ }
851
+
852
+ async function updateGatingConditionField(slideId, index, fieldName, value) {
853
+ const gating = currentSlideConfig?.navigation?.gating;
854
+ if (!gating?.conditions?.[index]) return;
855
+ gating.conditions[index][fieldName] = value;
856
+ await writeConfig('gating', slideId, gating);
857
+ }
858
+
859
+ async function saveSlideConfigValue(slideId, propPath, value) {
860
+ await writeConfig('slide', slideId, { [propPath]: value });
861
+ }
862
+
863
+ function renderObjectivesTab() {
864
+ const objectives = configData.objectives || [];
865
+ const slideIds = configData.slideIds || [];
866
+
867
+ if (objectives.length === 0) {
868
+ configBody.innerHTML = `
869
+ <div class="config-section">
870
+ <div class="config-section-header">Learning Objectives</div>
871
+ <p style="color: #6b7280; font-size: 12px; margin: 8px 0;">
872
+ No objectives defined in course-config.js
873
+ </p>
874
+ </div>
875
+ `;
876
+ return;
877
+ }
878
+
879
+ // Slide options available in configData.slideIds
880
+
881
+ let html = `
882
+ <div class="config-section">
883
+ <div class="config-section-header">Learning Objectives (${objectives.length})</div>
884
+ </div>
885
+ `;
886
+
887
+ for (const obj of objectives) {
888
+ const c = obj.criteria || {};
889
+ const criteriaType = c.type || 'none';
890
+
891
+ // Build criteria-specific fields
892
+ let criteriaFieldsHtml = '';
893
+
894
+ switch (criteriaType) {
895
+ case 'slideVisited':
896
+ criteriaFieldsHtml = `
897
+ <div class="config-row objective-criteria-field" data-criteria-type="slideVisited">
898
+ <span class="config-label">Slide</span>
899
+ <select class="objective-config-select" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.slideId">
900
+ <option value="">Select slide...</option>
901
+ ${slideIds.map(s =>
902
+ `<option value="${escapeHtml(s.id)}" ${c.slideId === s.id ? 'selected' : ''}>${escapeHtml(s.title)}</option>`
903
+ ).join('')}
904
+ </select>
905
+ </div>
906
+ `;
907
+ break;
908
+ case 'allSlidesVisited':
909
+ criteriaFieldsHtml = `
910
+ <div class="config-row objective-criteria-field" data-criteria-type="allSlidesVisited">
911
+ <span class="config-label">Slide IDs</span>
912
+ <input type="text" class="config-input objective-config-input" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.slideIds" value="${escapeHtml((c.slideIds || []).join(', '))}" placeholder="slide1, slide2, slide3">
913
+ </div>
914
+ `;
915
+ break;
916
+ case 'timeOnSlide':
917
+ criteriaFieldsHtml = `
918
+ <div class="config-row objective-criteria-field" data-criteria-type="timeOnSlide">
919
+ <span class="config-label">Slide</span>
920
+ <select class="objective-config-select" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.slideId">
921
+ <option value="">Select slide...</option>
922
+ ${slideIds.map(s =>
923
+ `<option value="${escapeHtml(s.id)}" ${c.slideId === s.id ? 'selected' : ''}>${escapeHtml(s.title)}</option>`
924
+ ).join('')}
925
+ </select>
926
+ </div>
927
+ <div class="config-row objective-criteria-field" data-criteria-type="timeOnSlide">
928
+ <span class="config-label">Min Seconds</span>
929
+ <input type="number" class="config-input objective-config-input" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.minSeconds" value="${c.minSeconds || 30}" min="1" style="width: 80px;">
930
+ </div>
931
+ `;
932
+ break;
933
+ case 'flag':
934
+ criteriaFieldsHtml = `
935
+ <div class="config-row objective-criteria-field" data-criteria-type="flag">
936
+ <span class="config-label">Flag Key</span>
937
+ <input type="text" class="config-input objective-config-input" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.key" value="${escapeHtml(c.key || '')}" placeholder="flag_key">
938
+ </div>
939
+ <div class="config-row objective-criteria-field" data-criteria-type="flag">
940
+ <span class="config-label">Equals Value</span>
941
+ <input type="text" class="config-input objective-config-input" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.equals" value="${c.equals !== undefined ? escapeHtml(String(c.equals)) : ''}" placeholder="true">
942
+ </div>
943
+ `;
944
+ break;
945
+ case 'allFlags':
946
+ criteriaFieldsHtml = `
947
+ <div class="config-row objective-criteria-field" data-criteria-type="allFlags">
948
+ <span class="config-label">Flag Keys</span>
949
+ <input type="text" class="config-input objective-config-input" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.flags" value="${escapeHtml((c.flags || []).map(f => typeof f === 'string' ? f : f.key).join(', '))}" placeholder="flag1, flag2, flag3">
950
+ </div>
951
+ `;
952
+ break;
953
+ }
954
+
955
+ html += `
956
+ <div class="config-objective-card" data-objective-id="${escapeHtml(obj.id)}">
957
+ <div class="config-row" style="margin-bottom: 8px;">
958
+ <span class="config-label">ID</span>
959
+ <input type="text" class="config-input objective-id-input" data-original-id="${escapeHtml(obj.id)}" value="${escapeHtml(obj.id)}" style="font-family: 'Consolas', 'Monaco', monospace; font-weight: 600; color: #f18701;">
960
+ </div>
961
+
962
+ <div class="config-row">
963
+ <span class="config-label">Description</span>
964
+ <input type="text" class="config-input objective-config-input" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="description" value="${escapeHtml(obj.description || '')}" placeholder="Objective description" style="flex: 1; max-width: 260px;">
965
+ </div>
966
+
967
+ <div class="config-row">
968
+ <span class="config-label">Initial Completion</span>
969
+ <select class="objective-config-select" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="initialCompletion">
970
+ <option value="incomplete" ${(obj.initialCompletion || 'incomplete') === 'incomplete' ? 'selected' : ''}>incomplete</option>
971
+ <option value="completed" ${obj.initialCompletion === 'completed' ? 'selected' : ''}>completed</option>
972
+ </select>
973
+ </div>
974
+
975
+ <div class="config-row">
976
+ <span class="config-label">Initial Success</span>
977
+ <select class="objective-config-select" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="initialSuccess">
978
+ <option value="unknown" ${(obj.initialSuccess || 'unknown') === 'unknown' ? 'selected' : ''}>unknown</option>
979
+ <option value="passed" ${obj.initialSuccess === 'passed' ? 'selected' : ''}>passed</option>
980
+ <option value="failed" ${obj.initialSuccess === 'failed' ? 'selected' : ''}>failed</option>
981
+ </select>
982
+ </div>
983
+
984
+ <div class="config-row">
985
+ <span class="config-label">Criteria Type</span>
986
+ <select class="objective-config-select objective-criteria-type-select" data-obj-id="${escapeHtml(obj.id)}" data-obj-path="criteria.type">
987
+ <option value="none" ${criteriaType === 'none' ? 'selected' : ''}>Manual (no auto-complete)</option>
988
+ <option value="slideVisited" ${criteriaType === 'slideVisited' ? 'selected' : ''}>slideVisited</option>
989
+ <option value="allSlidesVisited" ${criteriaType === 'allSlidesVisited' ? 'selected' : ''}>allSlidesVisited</option>
990
+ <option value="timeOnSlide" ${criteriaType === 'timeOnSlide' ? 'selected' : ''}>timeOnSlide</option>
991
+ <option value="flag" ${criteriaType === 'flag' ? 'selected' : ''}>flag</option>
992
+ <option value="allFlags" ${criteriaType === 'allFlags' ? 'selected' : ''}>allFlags</option>
993
+ </select>
994
+ </div>
995
+ ${criteriaFieldsHtml}
996
+ </div>
997
+ `;
998
+ }
999
+
1000
+ configBody.innerHTML = html;
1001
+
1002
+ // Bind objective handlers
1003
+ bindObjectiveHandlers();
1004
+ }
1005
+
1006
+ function bindObjectiveHandlers() {
1007
+ // Bind objective ID rename (with debounce on blur)
1008
+ configBody.querySelectorAll('.objective-id-input[data-original-id]').forEach(input => {
1009
+ let _timeout;
1010
+ input.addEventListener('blur', async function () {
1011
+ const oldId = this.dataset.originalId;
1012
+ const newId = this.value.trim();
1013
+
1014
+ if (!newId || oldId === newId) return;
1015
+
1016
+ await renameObjective(oldId, newId);
1017
+ });
1018
+ });
1019
+
1020
+ // Bind select changes
1021
+ configBody.querySelectorAll('.objective-config-select[data-obj-id]').forEach(sel => {
1022
+ sel.addEventListener('change', async function () {
1023
+ const objId = this.dataset.objId;
1024
+ const path = this.dataset.objPath;
1025
+ await saveObjectiveValue(objId, path, this.value);
1026
+
1027
+ // If criteria type changed, re-render to show/hide appropriate fields
1028
+ if (path === 'criteria.type') {
1029
+ // Reload objectives data and re-render
1030
+ await loadConfig();
1031
+ renderObjectivesTab();
1032
+ }
1033
+ });
1034
+ });
1035
+
1036
+ // Bind input changes (with debounce)
1037
+ configBody.querySelectorAll('.objective-config-input[data-obj-id]').forEach(input => {
1038
+ let timeout;
1039
+ input.addEventListener('input', function () {
1040
+ clearTimeout(timeout);
1041
+ timeout = setTimeout(async () => {
1042
+ const objId = this.dataset.objId;
1043
+ const path = this.dataset.objPath;
1044
+ let value = this.value;
1045
+
1046
+ if (this.type === 'number') {
1047
+ value = parseFloat(value);
1048
+ }
1049
+
1050
+ await saveObjectiveValue(objId, path, value);
1051
+ }, 500);
1052
+ });
1053
+ });
1054
+ }
1055
+
1056
+ async function saveObjectiveValue(objectiveId, propPath, value) {
1057
+ await writeConfig('objective', objectiveId, { [propPath]: value });
1058
+ }
1059
+
1060
+ async function renameObjective(oldId, newId) {
1061
+ const ok = await writeConfig('rename-objective', oldId, newId);
1062
+ if (!ok) {
1063
+ alert('Rename failed');
1064
+ }
1065
+ await loadConfig();
1066
+ renderObjectivesTab();
1067
+ }
1068
+
1069
+ function renderRawTab() {
1070
+ configBody.innerHTML = `
1071
+ <div class="config-section">
1072
+ <div class="config-section-header">Raw Config (Read Only)</div>
1073
+ <pre class="config-readonly">${escapeHtml(JSON.stringify(configData, null, 2))}</pre>
1074
+ </div>
1075
+ `;
1076
+ }
1077
+
1078
+ // === Engagement Tab ===
1079
+
1080
+ const REQUIREMENT_TYPES = [
1081
+ { value: 'timeOnSlide', label: 'Time on Slide' },
1082
+ { value: 'scrollDepth', label: 'Scroll Depth' },
1083
+ { value: 'interactionComplete', label: 'Interaction Complete' },
1084
+ { value: 'audioComplete', label: 'Audio Complete' },
1085
+ { value: 'modalAudioComplete', label: 'Modal Audio Complete' },
1086
+ { value: 'flag', label: 'Flag' },
1087
+ { value: 'allFlags', label: 'All Flags' },
1088
+ { value: 'viewAllTabs', label: 'View All Tabs' }
1089
+ ];
1090
+
1091
+ const REQUIREMENT_DEFAULTS = {
1092
+ timeOnSlide: { type: 'timeOnSlide', minSeconds: 30 },
1093
+ scrollDepth: { type: 'scrollDepth', percentage: 80 },
1094
+ interactionComplete: { type: 'interactionComplete', interactionId: '' },
1095
+ audioComplete: { type: 'audioComplete', audioId: '' },
1096
+ modalAudioComplete: { type: 'modalAudioComplete', modalId: '' },
1097
+ flag: { type: 'flag', key: '' },
1098
+ allFlags: { type: 'allFlags', flags: [] },
1099
+ viewAllTabs: { type: 'viewAllTabs', componentId: '' }
1100
+ };
1101
+
1102
+ async function renderEngagementTab() {
1103
+ if (!configData) return;
1104
+
1105
+ const slideIds = configData.slideIds || [];
1106
+ let html = '<div class="config-section"><div class="config-section-header">Engagement Requirements</div>';
1107
+ html += '<p class="config-description">Manage requirements across all slides. Only slides with engagement.required=true use requirements.</p>';
1108
+
1109
+ // Fetch all slide configs in parallel
1110
+ const slideConfigs = await Promise.all(
1111
+ slideIds.map(async (s) => {
1112
+ try {
1113
+ const res = await fetch('/__slide-config/' + encodeURIComponent(s.id));
1114
+ return res.ok ? await res.json() : null;
1115
+ } catch { return null; }
1116
+ })
1117
+ );
1118
+
1119
+ let hasAny = false;
1120
+ for (let si = 0; si < slideIds.length; si++) {
1121
+ const slide = slideConfigs[si];
1122
+ if (!slide) continue;
1123
+ const engagement = slide.engagement || {};
1124
+ const requirements = engagement.requirements || [];
1125
+ if (!engagement.required && requirements.length === 0) continue;
1126
+
1127
+ hasAny = true;
1128
+ const title = slide.title || slide.id;
1129
+ html += `
1130
+ <div class="engagement-slide-card" data-eng-slide="${escapeHtml(slide.id)}">
1131
+ <div class="engagement-slide-header">
1132
+ <span class="engagement-slide-title">${escapeHtml(title)}</span>
1133
+ <span class="engagement-slide-status ${engagement.required ? 'active' : 'inactive'}">${engagement.required ? 'Required' : 'Not required'}</span>
1134
+ </div>
1135
+ `;
1136
+
1137
+ for (let ri = 0; ri < requirements.length; ri++) {
1138
+ const req = requirements[ri];
1139
+ html += `
1140
+ <div class="engagement-req-group" data-eng-slide="${escapeHtml(slide.id)}" data-req-index="${ri}">
1141
+ <div class="engagement-req-header">
1142
+ <span class="engagement-req-number">Requirement ${ri + 1}</span>
1143
+ <button type="button" class="engagement-req-remove" data-eng-slide="${escapeHtml(slide.id)}" data-req-index="${ri}">✕</button>
1144
+ </div>
1145
+ <div class="config-row">
1146
+ <span class="config-label">Type</span>
1147
+ <select class="engagement-req-type" data-eng-slide="${escapeHtml(slide.id)}" data-req-index="${ri}">
1148
+ ${REQUIREMENT_TYPES.map(t => `<option value="${t.value}" ${req.type === t.value ? 'selected' : ''}>${t.label}</option>`).join('')}
1149
+ </select>
1150
+ </div>
1151
+ ${renderRequirementFields(req, slide.id, ri)}
1152
+ </div>
1153
+ `;
1154
+ }
1155
+
1156
+ html += `
1157
+ <button type="button" class="engagement-req-add" data-eng-slide="${escapeHtml(slide.id)}">+ Add Requirement</button>
1158
+ </div>
1159
+ `;
1160
+ }
1161
+
1162
+ if (!hasAny) {
1163
+ html += '<p class="config-empty">No slides have engagement requirements configured. Enable engagement.required on a slide first.</p>';
1164
+ }
1165
+
1166
+ html += '</div>';
1167
+ configBody.innerHTML = html;
1168
+ bindEngagementHandlers();
1169
+ }
1170
+
1171
+ function renderRequirementFields(req, slideId, index) {
1172
+ const prefix = `data-eng-slide="${escapeHtml(slideId)}" data-req-index="${index}"`;
1173
+ switch (req.type) {
1174
+ case 'timeOnSlide':
1175
+ return `<div class="config-row"><span class="config-label">Min Seconds</span><input type="number" class="engagement-req-field config-input" ${prefix} data-field="minSeconds" value="${req.minSeconds || 30}" min="1" style="width: 80px;"></div>`;
1176
+ case 'scrollDepth':
1177
+ return `<div class="config-row"><span class="config-label">Percentage</span><input type="number" class="engagement-req-field config-input" ${prefix} data-field="percentage" value="${req.percentage || 80}" min="1" max="100" style="width: 80px;"></div>`;
1178
+ case 'interactionComplete':
1179
+ return `<div class="config-row"><span class="config-label">Interaction ID</span><input type="text" class="engagement-req-field config-input" ${prefix} data-field="interactionId" value="${escapeHtml(req.interactionId || '')}" placeholder="interaction-id"></div>`;
1180
+ case 'audioComplete':
1181
+ return `<div class="config-row"><span class="config-label">Audio ID</span><input type="text" class="engagement-req-field config-input" ${prefix} data-field="audioId" value="${escapeHtml(req.audioId || '')}" placeholder="audio-id"></div>`;
1182
+ case 'modalAudioComplete':
1183
+ return `<div class="config-row"><span class="config-label">Modal ID</span><input type="text" class="engagement-req-field config-input" ${prefix} data-field="modalId" value="${escapeHtml(req.modalId || '')}" placeholder="modal-id"></div>`;
1184
+ case 'flag':
1185
+ return `<div class="config-row"><span class="config-label">Key</span><input type="text" class="engagement-req-field config-input" ${prefix} data-field="key" value="${escapeHtml(req.key || '')}" placeholder="flag_key"></div>`;
1186
+ case 'viewAllTabs':
1187
+ return `<div class="config-row"><span class="config-label">Component ID</span><input type="text" class="engagement-req-field config-input" ${prefix} data-field="componentId" value="${escapeHtml(req.componentId || '')}" placeholder="tabs-id"></div>`;
1188
+ default:
1189
+ return '';
1190
+ }
1191
+ }
1192
+
1193
+ function bindEngagementHandlers() {
1194
+ // Add requirement
1195
+ configBody.querySelectorAll('.engagement-req-add').forEach(btn => {
1196
+ btn.addEventListener('click', async () => {
1197
+ const slideId = btn.dataset.engSlide;
1198
+ const slideRes = await fetch('/__slide-config/' + encodeURIComponent(slideId));
1199
+ if (!slideRes.ok) return;
1200
+ const slide = await slideRes.json();
1201
+ const reqs = [...(slide.engagement?.requirements || []), { ...REQUIREMENT_DEFAULTS.timeOnSlide }];
1202
+ await writeConfig('slide', slideId, { 'engagement.requirements': reqs });
1203
+ renderEngagementTab();
1204
+ });
1205
+ });
1206
+
1207
+ // Remove requirement
1208
+ configBody.querySelectorAll('.engagement-req-remove').forEach(btn => {
1209
+ btn.addEventListener('click', async () => {
1210
+ const slideId = btn.dataset.engSlide;
1211
+ const index = parseInt(btn.dataset.reqIndex, 10);
1212
+ const slideRes = await fetch('/__slide-config/' + encodeURIComponent(slideId));
1213
+ if (!slideRes.ok) return;
1214
+ const slide = await slideRes.json();
1215
+ const reqs = [...(slide.engagement?.requirements || [])];
1216
+ reqs.splice(index, 1);
1217
+ await writeConfig('slide', slideId, { 'engagement.requirements': reqs });
1218
+ renderEngagementTab();
1219
+ });
1220
+ });
1221
+
1222
+ // Type change
1223
+ configBody.querySelectorAll('.engagement-req-type').forEach(sel => {
1224
+ sel.addEventListener('change', async () => {
1225
+ const slideId = sel.dataset.engSlide;
1226
+ const index = parseInt(sel.dataset.reqIndex, 10);
1227
+ const slideRes = await fetch('/__slide-config/' + encodeURIComponent(slideId));
1228
+ if (!slideRes.ok) return;
1229
+ const slide = await slideRes.json();
1230
+ const reqs = [...(slide.engagement?.requirements || [])];
1231
+ reqs[index] = { ...(REQUIREMENT_DEFAULTS[sel.value] || { type: sel.value }) };
1232
+ await writeConfig('slide', slideId, { 'engagement.requirements': reqs });
1233
+ renderEngagementTab();
1234
+ });
1235
+ });
1236
+
1237
+ // Field change (debounced)
1238
+ configBody.querySelectorAll('.engagement-req-field').forEach(field => {
1239
+ let timeout;
1240
+ const handler = async () => {
1241
+ const slideId = field.dataset.engSlide;
1242
+ const index = parseInt(field.dataset.reqIndex, 10);
1243
+ const fieldName = field.dataset.field;
1244
+ let value = field.value;
1245
+ if (field.type === 'number') value = parseFloat(value);
1246
+
1247
+ const slideRes = await fetch('/__slide-config/' + encodeURIComponent(slideId));
1248
+ if (!slideRes.ok) return;
1249
+ const slide = await slideRes.json();
1250
+ const reqs = [...(slide.engagement?.requirements || [])];
1251
+ if (reqs[index]) {
1252
+ reqs[index] = { ...reqs[index], [fieldName]: value };
1253
+ await writeConfig('slide', slideId, { 'engagement.requirements': reqs });
1254
+ }
1255
+ };
1256
+ field.addEventListener('input', () => { clearTimeout(timeout); timeout = setTimeout(handler, 500); });
1257
+ field.addEventListener('change', handler);
1258
+ });
1259
+ }
1260
+
1261
+ async function saveThemeValue(token, value) {
1262
+ try {
1263
+ const response = await fetch('/__theme-edit', {
1264
+ method: 'POST',
1265
+ headers: { 'Content-Type': 'application/json' },
1266
+ body: JSON.stringify({ token, value })
1267
+ });
1268
+ if (!response.ok) {
1269
+ console.error('Theme save failed');
1270
+ }
1271
+ } catch (err) {
1272
+ console.error('Theme save error:', err);
1273
+ }
1274
+ }
1275
+
1276
+ async function saveConfigValue(path, value) {
1277
+ try {
1278
+ const response = await fetch('/__write', {
1279
+ method: 'POST',
1280
+ headers: { 'Content-Type': 'application/json' },
1281
+ body: JSON.stringify({ target: 'config', id: path, value })
1282
+ });
1283
+ if (!response.ok) {
1284
+ const err = await response.json();
1285
+ console.error('Config save error:', err);
1286
+ }
1287
+ } catch (err) {
1288
+ console.error('Failed to save config:', err);
1289
+ }
1290
+ }
1291
+
1292
+ // Expose for refresh when slide changes
1293
+ window.__refreshSlideTab = () => {
1294
+ if (currentConfigTab === 'slide' && document.getElementById('stub-player-config-panel').classList.contains('visible')) {
1295
+ renderSlideTab();
1296
+ }
1297
+ };
1298
+
1299
+ return {
1300
+ loadConfig,
1301
+ render: renderConfigTab
1302
+ };
1303
+ }