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,699 @@
1
+ import {
2
+ validateAgainstSchema,
3
+ createInteractionEventHandler,
4
+ renderInteractionControls,
5
+ renderFeedbackContainer,
6
+ displayFeedback,
7
+ clearFeedback,
8
+ validateContainer,
9
+ escapeCssSelector,
10
+ registerCoreInteraction
11
+ } from './interaction-base.js';
12
+
13
+ // Metadata for matching interaction type
14
+ export const metadata = {
15
+ creator: 'createMatchingQuestion',
16
+ scormType: 'matching',
17
+ showCheckAnswer: true,
18
+ isAnswered: (response) => {
19
+ if (!response || typeof response !== 'object') return false;
20
+ return Object.keys(response).length > 0 && Object.values(response).some(val => val && String(val).trim().length > 0);
21
+ },
22
+ getCorrectAnswer: (config) => {
23
+ if (!config.pairs || !Array.isArray(config.pairs)) {
24
+ return '';
25
+ }
26
+ return JSON.stringify(config.pairs.reduce((acc, pair) => {
27
+ acc[pair.id] = pair.match;
28
+ return acc;
29
+ }, {}));
30
+ },
31
+ formatCorrectAnswer: (question, correctAnswer) => {
32
+ let html = '';
33
+ if (question.pairs && Array.isArray(question.pairs)) {
34
+ html += '<ul class="list-disc pl-4 m-0">';
35
+ question.pairs.forEach(pair => {
36
+ const matchText = pair.match || '';
37
+ html += `<li class="correct-item">${pair.text} → ${matchText}</li>`;
38
+ });
39
+ html += '</ul>';
40
+ } else {
41
+ html += `<p class="correct-item">${correctAnswer}</p>`;
42
+ }
43
+ return html;
44
+ },
45
+ formatUserResponse: (question, response) => {
46
+ let html = '';
47
+ try {
48
+ const userMatches = typeof response === 'string' ? JSON.parse(response) : response;
49
+ if (question.pairs && Array.isArray(question.pairs)) {
50
+ html += '<ul class="list-disc pl-4 m-0">';
51
+ question.pairs.forEach(pair => {
52
+ const matched = userMatches[pair.id] || '(not matched)';
53
+ html += `<li class="response-item">${pair.text} → ${matched}</li>`;
54
+ });
55
+ html += '</ul>';
56
+ } else {
57
+ html += `<p class="response-item">${JSON.stringify(userMatches)}</p>`;
58
+ }
59
+ } catch (_error) {
60
+ html += `<p class="response-item">${response}</p>`;
61
+ }
62
+ return html;
63
+ }
64
+ };
65
+
66
+ // Schema for validation, linting, and AI-assisted authoring
67
+ export const schema = {
68
+ type: 'matching',
69
+ description: 'Match source items to their correct targets',
70
+ scormType: 'matching',
71
+ example: `<div class="interaction matching-interaction matching-deferred" data-interaction-id="demo-matching">
72
+ <div class="question-prompt"><h3>Match each item to its pair</h3></div>
73
+ <div class="matching-container">
74
+ <div class="matching-column matching-items">
75
+ <h4 class="matching-column-header">Items</h4>
76
+ <div class="matching-list">
77
+ <button type="button" class="matching-item matched" data-item-id="p1" style="--pair-color: #9333ea;" disabled><span class="matching-item-text">HTML</span></button>
78
+ <button type="button" class="matching-item" data-item-id="p2"><span class="matching-item-text">CSS</span></button>
79
+ <button type="button" class="matching-item" data-item-id="p3"><span class="matching-item-text">JavaScript</span></button>
80
+ </div>
81
+ </div>
82
+ <div class="matching-column matching-targets">
83
+ <h4 class="matching-column-header">Matches</h4>
84
+ <div class="matching-list">
85
+ <button type="button" class="matching-target" data-match-id="p3"><span class="matching-target-text">Interactivity</span></button>
86
+ <button type="button" class="matching-target matched" data-match-id="p1" style="--pair-color: #9333ea;" disabled><span class="matching-target-text">Structure</span></button>
87
+ <button type="button" class="matching-target" data-match-id="p2"><span class="matching-target-text">Styling</span></button>
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </div>`,
92
+ properties: {
93
+ pairs: {
94
+ type: 'array',
95
+ required: true,
96
+ minItems: 2,
97
+ description: 'Source-target pairs to match',
98
+ itemSchema: {
99
+ source: { type: 'string', required: true },
100
+ target: { type: 'string', required: true }
101
+ }
102
+ },
103
+ feedbackMode: {
104
+ type: 'string',
105
+ enum: ['immediate', 'deferred'],
106
+ default: 'deferred',
107
+ description: 'When to show match feedback'
108
+ }
109
+ }
110
+ };
111
+
112
+ /**
113
+ * Creates a matching question interaction
114
+ * @param {Object} config - Configuration object
115
+ * @param {string} config.id - Unique identifier
116
+ * @param {string} config.prompt - Question prompt
117
+ * @param {Array} config.pairs - Array of {id, text, match} pairs
118
+ * @param {Object} config.feedback - Optional feedback messages
119
+ * @param {boolean} config.controlled - Whether to use controlled mode
120
+ * @param {string} config.feedbackMode - 'immediate' | 'deferred' (default: 'deferred')
121
+ * - immediate: Green/red feedback on each match, no check button
122
+ * - deferred: Visual connections, check all at once with smart feedback
123
+ * @returns {Object} Question object with render, evaluate, checkAnswer, reset, getResponse, setResponse methods
124
+ */
125
+ export function createMatchingQuestion(config) {
126
+ validateAgainstSchema(config, schema);
127
+
128
+ const {
129
+ id,
130
+ prompt,
131
+ pairs,
132
+ controlled = false,
133
+ feedbackMode = 'deferred'
134
+ } = config;
135
+
136
+ // Validate pairs array
137
+ if (!Array.isArray(pairs) || pairs.length === 0) {
138
+ throw new Error(`Matching question "${id}" must have at least one pair`);
139
+ }
140
+
141
+ // Validate pair structure
142
+ pairs.forEach((pair, index) => {
143
+ if (!pair.id || !pair.text || !pair.match) {
144
+ throw new Error(`Matching question "${id}" pair at index ${index} must have id, text, and match properties`);
145
+ }
146
+ });
147
+
148
+ let _container = null;
149
+ let _pairsData = null;
150
+ const _colors = ['#9333ea', '#ec4899', '#f59e0b', '#3b82f6', '#6366f1', '#8b5cf6', '#06b6d4', '#14b8a6'];
151
+
152
+ const questionObj = {
153
+ id,
154
+ type: 'matching',
155
+ pairs,
156
+ feedbackMode,
157
+
158
+ render: (container) => {
159
+ validateContainer(container, id);
160
+ _container = container;
161
+
162
+ // Create shuffled match options (right column)
163
+ const matchOptions = pairs.map(p => ({ id: p.id, match: p.match }));
164
+ shuffleArray(matchOptions);
165
+
166
+ // Store pairs data for later reference
167
+ _pairsData = {
168
+ items: pairs.map(p => ({ id: p.id, text: p.text })),
169
+ matches: matchOptions,
170
+ correctAnswers: pairs.reduce((acc, p) => {
171
+ acc[p.id] = p.match;
172
+ return acc;
173
+ }, {})
174
+ };
175
+
176
+ const modeClass = feedbackMode === 'immediate' ? 'matching-immediate' : 'matching-deferred';
177
+ const instructionText = feedbackMode === 'immediate'
178
+ ? 'Click an item on the left, then its correct match on the right.'
179
+ : 'Click an item on the left, then click its match on the right.';
180
+
181
+ let html = `
182
+ <div class="interaction matching-interaction ${modeClass}" data-interaction-id="${id}" data-feedback-mode="${feedbackMode}">
183
+ <div class="question-prompt">
184
+ <h3>${prompt}</h3>
185
+ <p class="matching-instruction">${instructionText}</p>
186
+ </div>
187
+ <div class="matching-container">
188
+ <div class="matching-column matching-items">
189
+ <h4 class="matching-column-header">Items</h4>
190
+ <div class="matching-list">
191
+ `;
192
+
193
+ pairs.forEach((pair) => {
194
+ html += `
195
+ <button type="button"
196
+ class="matching-item"
197
+ data-item-id="${pair.id}"
198
+ data-testid="${id}-match-item-${pair.id}"
199
+ aria-label="Match item: ${pair.text}">
200
+ <span class="matching-item-text">${pair.text}</span>
201
+ ${feedbackMode === 'immediate' ? '<span class="matching-feedback-icon" aria-hidden="true"></span>' : ''}
202
+ </button>
203
+ `;
204
+ });
205
+
206
+ html += `
207
+ </div>
208
+ </div>
209
+ <div class="matching-column matching-targets">
210
+ <h4 class="matching-column-header">Matches</h4>
211
+ <div class="matching-list">
212
+ `;
213
+
214
+ matchOptions.forEach((option) => {
215
+ html += `
216
+ <button type="button"
217
+ class="matching-target"
218
+ data-match-id="${option.id}"
219
+ data-testid="${id}-match-target-${option.id}"
220
+ aria-label="Match target: ${option.match}">
221
+ <span class="matching-target-text">${option.match}</span>
222
+ ${feedbackMode === 'deferred' ? '<span class="matching-connection-indicator" aria-hidden="true"></span>' : ''}
223
+ </button>
224
+ `;
225
+ });
226
+
227
+ html += `
228
+ </div>
229
+ </div>
230
+ </div>
231
+ ${feedbackMode === 'deferred' ? renderInteractionControls(id, controlled) : ''}
232
+ ${renderFeedbackContainer(id)}
233
+ </div>
234
+ `;
235
+
236
+ container.innerHTML = html;
237
+
238
+ // Setup interaction state and handlers
239
+ setupMatchingInteraction(container, questionObj, _pairsData, feedbackMode, _colors);
240
+
241
+ // Attach event handler only in uncontrolled mode AND deferred mode
242
+ if (!controlled && feedbackMode === 'deferred') {
243
+ const correctPattern = JSON.stringify(_pairsData.correctAnswers);
244
+
245
+ container.addEventListener('click', createInteractionEventHandler(questionObj, {
246
+ ...config,
247
+ scormType: 'matching',
248
+ correctPattern
249
+ }));
250
+ }
251
+ },
252
+
253
+ evaluate: (matches) => {
254
+ if (!matches || typeof matches !== 'object') {
255
+ return {
256
+ score: 0,
257
+ correct: false,
258
+ results: [],
259
+ response: JSON.stringify({}),
260
+ error: 'Invalid matches format'
261
+ };
262
+ }
263
+
264
+ let correct = 0;
265
+ const results = [];
266
+
267
+ pairs.forEach(pair => {
268
+ const userAnswer = matches[pair.id];
269
+ const correctAnswer = pair.match;
270
+ const isCorrect = userAnswer && correctAnswer &&
271
+ userAnswer.trim() === correctAnswer.trim();
272
+
273
+ if (isCorrect) correct++;
274
+ results.push({
275
+ itemId: pair.id,
276
+ userAnswer: userAnswer || null,
277
+ correctAnswer: pair.match,
278
+ correct: isCorrect
279
+ });
280
+ });
281
+
282
+ return {
283
+ score: correct / pairs.length,
284
+ correct: correct === pairs.length,
285
+ results,
286
+ response: JSON.stringify(matches)
287
+ };
288
+ },
289
+
290
+ checkAnswer: () => {
291
+ validateContainer(_container, id);
292
+
293
+ const matches = questionObj.getResponse();
294
+ const evaluation = questionObj.evaluate(matches);
295
+
296
+ // Show visual feedback on items
297
+ if (feedbackMode === 'deferred') {
298
+ visuallyShowResults(_container, evaluation.results);
299
+ }
300
+
301
+ if (evaluation.correct) {
302
+ const feedbackMsg = config.feedback?.correct || 'Excellent! All matches are correct.';
303
+ displayFeedback(_container, id, feedbackMsg, 'correct');
304
+ } else {
305
+ const feedbackMsg = config.feedback?.incorrect || `${Math.round(evaluation.score * 100)}% correct. Review your matches.`;
306
+ displayFeedback(_container, id, feedbackMsg, 'incorrect');
307
+ }
308
+
309
+ return evaluation;
310
+ },
311
+
312
+ reset: () => {
313
+ validateContainer(_container, id);
314
+
315
+ // Clear all visual states
316
+ _container.querySelectorAll('.matching-item').forEach(item => {
317
+ item.classList.remove('selected', 'matched', 'correct', 'incorrect', 'flash-incorrect');
318
+ item.disabled = false;
319
+ item.style.removeProperty('--pair-color');
320
+ });
321
+
322
+ _container.querySelectorAll('.matching-target').forEach(target => {
323
+ target.classList.remove('selected', 'matched', 'correct', 'incorrect');
324
+ target.disabled = false;
325
+ target.style.removeProperty('--pair-color');
326
+ });
327
+
328
+ clearFeedback(_container, id);
329
+
330
+ // Reset internal state
331
+ const stateData = _container._matchingState;
332
+ if (stateData) {
333
+ stateData.matches = {};
334
+ stateData.selectedItem = null;
335
+ stateData.selectedTarget = null;
336
+ stateData.colorIndex = 0;
337
+ }
338
+ },
339
+
340
+ getResponse: () => {
341
+ validateContainer(_container, id);
342
+ const state = _container._matchingState;
343
+ return state ? { ...state.matches } : {};
344
+ },
345
+
346
+ setResponse: (matches) => {
347
+ validateContainer(_container, id);
348
+
349
+ // Handle array format from automation API (e.g., [{source: "pair-1", target: "pair-1"}])
350
+ let processedMatches = matches;
351
+ if (Array.isArray(matches)) {
352
+ processedMatches = {};
353
+ matches.forEach(item => {
354
+ if (item.source && item.target) {
355
+ // Map source ID to target ID/text
356
+ // The automation API might pass the target ID or the target text.
357
+ // The internal state expects { itemId: matchText }.
358
+ // If the input is { source: "pair-1", target: "pair-1" }, we need to know if "pair-1" is the ID or text.
359
+ // In this component, match options have IDs same as pair IDs usually, but text is what's stored in state.matches.
360
+
361
+ // Let's try to find the match text corresponding to the target ID if possible
362
+ const targetId = item.target;
363
+ const targetOption = _pairsData.matches.find(m => m.id === targetId);
364
+ const matchText = targetOption ? targetOption.match : item.target;
365
+
366
+ processedMatches[item.source] = matchText;
367
+ }
368
+ });
369
+ }
370
+
371
+ if (!processedMatches || typeof processedMatches !== 'object') {
372
+ throw new Error(`setResponse expects an object for matching question "${id}"`);
373
+ }
374
+
375
+ questionObj.reset();
376
+
377
+ const state = _container._matchingState;
378
+ if (state) {
379
+ state.matches = { ...processedMatches };
380
+
381
+ // Update UI to reflect matches
382
+ Object.entries(processedMatches).forEach(([itemId, matchText]) => {
383
+ const item = _container.querySelector(`.matching-item[data-item-id="${escapeCssSelector(itemId)}"]`);
384
+ const target = Array.from(_container.querySelectorAll('.matching-target'))
385
+ .find(t => t.querySelector('.matching-target-text')?.textContent.trim() === matchText);
386
+
387
+ if (item && target) {
388
+ item.classList.add('matched');
389
+ target.classList.add('matched');
390
+ item.disabled = true;
391
+ target.disabled = true;
392
+
393
+ if (feedbackMode === 'deferred') {
394
+ const color = _colors[state.colorIndex % _colors.length];
395
+ item.style.setProperty('--pair-color', color);
396
+ target.style.setProperty('--pair-color', color);
397
+ state.colorIndex++;
398
+ }
399
+ }
400
+ });
401
+ }
402
+ },
403
+
404
+ getCorrectAnswer: () => {
405
+ return pairs.reduce((acc, pair) => {
406
+ acc[pair.id] = pair.match;
407
+ return acc;
408
+ }, {});
409
+ }
410
+ };
411
+
412
+ // For uncontrolled interactions, register with the central registry for lifecycle mgmt
413
+ if (!controlled) {
414
+ registerCoreInteraction(config, questionObj);
415
+ }
416
+
417
+ return questionObj;
418
+ }
419
+
420
+ /**
421
+ * Shuffles array in place
422
+ */
423
+ function shuffleArray(array) {
424
+ for (let i = array.length - 1; i > 0; i--) {
425
+ const j = Math.floor(Math.random() * (i + 1));
426
+ [array[i], array[j]] = [array[j], array[i]];
427
+ }
428
+ return array;
429
+ }
430
+
431
+ /**
432
+ * Visually show results for deferred feedback mode
433
+ */
434
+ function visuallyShowResults(container, results) {
435
+ results.forEach(result => {
436
+ const item = container.querySelector(`.matching-item[data-item-id="${escapeCssSelector(result.itemId)}"]`);
437
+ const target = Array.from(container.querySelectorAll('.matching-target'))
438
+ .find(t => t.querySelector('.matching-target-text')?.textContent.trim() === result.userAnswer);
439
+
440
+ if (result.correct) {
441
+ // Keep green for correct
442
+ if (item) {
443
+ item.classList.remove('matched');
444
+ item.classList.add('correct');
445
+ }
446
+ if (target) {
447
+ target.classList.remove('matched');
448
+ target.classList.add('correct');
449
+ }
450
+ } else {
451
+ // Flash red then reset incorrect ones
452
+ if (item) {
453
+ item.classList.add('flash-incorrect');
454
+ setTimeout(() => {
455
+ item.classList.remove('flash-incorrect', 'matched');
456
+ item.disabled = false;
457
+ item.style.removeProperty('--pair-color');
458
+ }, 1000);
459
+ }
460
+ if (target) {
461
+ target.classList.add('flash-incorrect');
462
+ setTimeout(() => {
463
+ target.classList.remove('flash-incorrect', 'matched');
464
+ target.disabled = false;
465
+ target.style.removeProperty('--pair-color');
466
+ }, 1000);
467
+ }
468
+
469
+ // Remove from state after animation
470
+ const state = container._matchingState;
471
+ if (state) {
472
+ setTimeout(() => {
473
+ delete state.matches[result.itemId];
474
+ }, 1000);
475
+ }
476
+ }
477
+ });
478
+ }
479
+
480
+ /**
481
+ * Sets up the matching interaction state and click handlers
482
+ */
483
+ function setupMatchingInteraction(container, questionObj, pairsData, feedbackMode, colors) {
484
+ container._matchingState = {
485
+ matches: {},
486
+ selectedItem: null,
487
+ selectedTarget: null,
488
+ pairsData,
489
+ colorIndex: 0
490
+ };
491
+
492
+ container.addEventListener('click', (event) => {
493
+ const state = container._matchingState;
494
+
495
+ const matchingItem = event.target.closest('.matching-item');
496
+ if (matchingItem && !matchingItem.disabled) {
497
+ if (state.selectedTarget) {
498
+ performMatch(container, state, matchingItem, state.selectedTarget, pairsData, feedbackMode, colors);
499
+ return;
500
+ }
501
+ handleItemClick(container, state, matchingItem, feedbackMode, colors);
502
+ return;
503
+ }
504
+
505
+ const matchingTarget = event.target.closest('.matching-target');
506
+ if (matchingTarget && !matchingTarget.disabled) {
507
+ if (state.selectedItem) {
508
+ performMatch(container, state, state.selectedItem, matchingTarget, pairsData, feedbackMode, colors);
509
+ return;
510
+ }
511
+ if (matchingTarget.classList.contains('matched')) {
512
+ unmatchTarget(container, state, matchingTarget, feedbackMode);
513
+ return;
514
+ }
515
+ handleTargetClick(container, state, matchingTarget, feedbackMode, colors);
516
+ }
517
+ });
518
+
519
+ container.addEventListener('keydown', (event) => {
520
+ if (event.key === 'Enter' || event.key === ' ') {
521
+ event.preventDefault();
522
+ const target = event.target;
523
+ if (target.classList.contains('matching-item') || target.classList.contains('matching-target')) {
524
+ target.click();
525
+ }
526
+ }
527
+ });
528
+ }
529
+
530
+ function handleItemClick(container, state, item, feedbackMode, colors) {
531
+ if (item.classList.contains('matched')) {
532
+ unmatchItem(container, state, item);
533
+ return;
534
+ }
535
+
536
+ state.selectedItem = item;
537
+ state.selectedTarget = null;
538
+
539
+ // In deferred mode, set the color FIRST before any DOM changes
540
+ if (feedbackMode === 'deferred' && colors) {
541
+ const nextColor = colors[state.colorIndex % colors.length];
542
+ item.style.setProperty('--selection-color', nextColor);
543
+ }
544
+
545
+ // Now clear other selections
546
+ container.querySelectorAll('.matching-item').forEach(i => {
547
+ i.classList.remove('selected');
548
+ if (i !== item) {
549
+ i.style.removeProperty('--selection-color');
550
+ }
551
+ });
552
+ container.querySelectorAll('.matching-target').forEach(t => {
553
+ t.classList.remove('selected');
554
+ t.style.removeProperty('--selection-color');
555
+ });
556
+
557
+ item.classList.add('selected');
558
+ }
559
+
560
+ function handleTargetClick(container, state, target, feedbackMode, colors) {
561
+ state.selectedTarget = target;
562
+ state.selectedItem = null;
563
+
564
+ // In deferred mode, set the color FIRST before any DOM changes
565
+ if (feedbackMode === 'deferred' && colors) {
566
+ const nextColor = colors[state.colorIndex % colors.length];
567
+ target.style.setProperty('--selection-color', nextColor);
568
+ }
569
+
570
+ // Now clear other selections
571
+ container.querySelectorAll('.matching-item').forEach(i => {
572
+ i.classList.remove('selected');
573
+ i.style.removeProperty('--selection-color');
574
+ });
575
+ container.querySelectorAll('.matching-target').forEach(t => {
576
+ t.classList.remove('selected');
577
+ if (t !== target) {
578
+ t.style.removeProperty('--selection-color');
579
+ }
580
+ });
581
+
582
+ target.classList.add('selected');
583
+ }
584
+
585
+ function performMatch(container, state, item, target, pairsData, feedbackMode, colors) {
586
+ const itemId = item.dataset.itemId;
587
+ const matchText = target.querySelector('.matching-target-text').textContent.trim();
588
+
589
+ // Pre-emptively set the selection color on the target to prevent focus flicker.
590
+ // The target is about to be matched, so it should share the same color
591
+ // as the already-selected item.
592
+ if (feedbackMode === 'deferred') {
593
+ const currentColor = colors[state.colorIndex % colors.length];
594
+ target.style.setProperty('--selection-color', currentColor);
595
+ }
596
+
597
+ const correctAnswer = pairsData.correctAnswers[itemId];
598
+ const isCorrect = matchText === correctAnswer;
599
+
600
+ // Immediate feedback mode
601
+ if (feedbackMode === 'immediate') {
602
+ if (isCorrect) {
603
+ item.classList.remove('selected');
604
+ item.classList.add('matched', 'correct');
605
+ item.disabled = true;
606
+
607
+ target.classList.remove('selected');
608
+ target.classList.add('matched', 'correct');
609
+ target.disabled = true;
610
+
611
+ state.matches[itemId] = matchText;
612
+ } else {
613
+ // Flash red then reset
614
+ item.classList.add('incorrect');
615
+ target.classList.add('incorrect');
616
+
617
+ setTimeout(() => {
618
+ item.classList.remove('incorrect', 'selected');
619
+ target.classList.remove('incorrect', 'selected');
620
+ }, 600);
621
+ }
622
+
623
+ state.selectedItem = null;
624
+ state.selectedTarget = null;
625
+ return;
626
+ }
627
+
628
+ // Deferred feedback mode - allow any match with color coding
629
+ const existingItemId = Object.keys(state.matches).find(id => state.matches[id] === matchText);
630
+ if (existingItemId && existingItemId !== itemId) {
631
+ const prevItem = container.querySelector(`.matching-item[data-item-id="${escapeCssSelector(existingItemId)}"]`);
632
+ if (prevItem) {
633
+ prevItem.classList.remove('matched');
634
+ prevItem.style.removeProperty('--pair-color');
635
+ }
636
+ delete state.matches[existingItemId];
637
+ }
638
+
639
+ if (state.matches[itemId]) {
640
+ const prevMatchText = state.matches[itemId];
641
+ const prevTarget = Array.from(container.querySelectorAll('.matching-target'))
642
+ .find(t => t.querySelector('.matching-target-text')?.textContent.trim() === prevMatchText);
643
+ if (prevTarget) {
644
+ prevTarget.classList.remove('matched');
645
+ prevTarget.style.removeProperty('--pair-color');
646
+ }
647
+ }
648
+
649
+ const color = colors[state.colorIndex % colors.length];
650
+ state.colorIndex++;
651
+
652
+ item.classList.remove('selected');
653
+ item.classList.add('matched');
654
+ // Don't disable in deferred mode - allow rematching
655
+ item.style.setProperty('--pair-color', color);
656
+
657
+ target.classList.remove('selected');
658
+ target.classList.add('matched');
659
+ // Don't disable in deferred mode - allow rematching
660
+ target.style.setProperty('--pair-color', color);
661
+
662
+ state.matches[itemId] = matchText;
663
+ state.selectedItem = null;
664
+ state.selectedTarget = null;
665
+ }
666
+
667
+ function unmatchItem(container, state, item) {
668
+ const itemId = item.dataset.itemId;
669
+ const matchText = state.matches[itemId];
670
+ if (!matchText) return;
671
+
672
+ const target = Array.from(container.querySelectorAll('.matching-target'))
673
+ .find(t => t.querySelector('.matching-target-text')?.textContent.trim() === matchText);
674
+
675
+ if (target) {
676
+ target.classList.remove('matched', 'correct');
677
+ target.style.removeProperty('--pair-color');
678
+ }
679
+
680
+ item.classList.remove('matched', 'correct');
681
+ item.style.removeProperty('--pair-color');
682
+ delete state.matches[itemId];
683
+ }
684
+
685
+ function unmatchTarget(container, state, target, _feedbackMode) {
686
+ const matchText = target.querySelector('.matching-target-text').textContent.trim();
687
+ const itemId = Object.keys(state.matches).find(id => state.matches[id] === matchText);
688
+ if (!itemId) return;
689
+
690
+ const item = container.querySelector(`.matching-item[data-item-id="${escapeCssSelector(itemId)}"]`);
691
+ if (item) {
692
+ item.classList.remove('matched', 'correct');
693
+ item.style.removeProperty('--pair-color');
694
+ }
695
+
696
+ target.classList.remove('matched', 'correct');
697
+ target.style.removeProperty('--pair-color');
698
+ delete state.matches[itemId];
699
+ }