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,1725 @@
1
+ /**
2
+ * @file runtime-linter.js
3
+ * @description Static analysis tool for validating course configuration at development time.
4
+ * Catches impossible-to-complete slides and configuration errors before runtime.
5
+ *
6
+ * This tool runs ONLY in development mode and will halt course initialization if errors are found.
7
+ *
8
+ * Uses shared validation rules from lib/validation-rules.js for consistency with CLI linting.
9
+ *
10
+ * Suppression: Add data-lint-ignore to any element to suppress warnings for it and its children.
11
+ * data-lint-ignore — suppress ALL lint warnings
12
+ * data-lint-ignore="spacing" — suppress only spacing warnings
13
+ * data-lint-ignore="spacing,contrast" — suppress multiple categories
14
+ *
15
+ * Categories: spacing, contrast, target-size, proximity, overlap, list-style, css-class
16
+ *
17
+ * @author Seth
18
+ * @version 2.1.0
19
+ */
20
+
21
+ import { logger } from '../utilities/logger.js';
22
+ import interactionRegistry from '../managers/interaction-registry.js';
23
+ import { getComponentSchema, getComponentMetadata, isComponentRegistered, getRegisteredComponentTypes } from '../core/component-catalog.js';
24
+
25
+ import {
26
+ flattenStructure,
27
+ registerInteractionId,
28
+ validateAssessmentConfig,
29
+ validateGatingConditions
30
+ } from '@lib/validation-rules.js';
31
+
32
+ // Dynamic class patterns that are valid even if not in stylesheets.
33
+ // Kept in sync with lib/build-linter.js — these are JS-state classes, functional
34
+ // selectors, and component-internal classes that have no corresponding CSS rules.
35
+ const DYNAMIC_CLASS_PREFIXES = ['js-', 'is-', 'animate-', 'delay-', 'icon-'];
36
+ const DYNAMIC_CLASSES = new Set([
37
+ 'active', 'open', 'closed', 'hidden', 'visible', 'disabled', 'loading',
38
+ 'collapsed', 'expanded', 'selected', 'checked', 'focused', 'hover',
39
+ 'entering', 'leaving', 'mounted',
40
+ // JS-functional selectors — queried by JS components, no CSS rules needed
41
+ 'dropdown-text', 'tabs',
42
+ // Component-internal classes — styled via [data-component] selectors in individual component CSS files
43
+ 'intro-card', 'card-icon',
44
+ // Interaction-internal classes — used by interaction JS for DOM structure
45
+ 'drag-drop', 'matching-items', 'matching-targets',
46
+ // Slide-specific JS selectors — queried by slide scripts for event binding
47
+ 'resources', 'complete-remedial-btn',
48
+ ]);
49
+
50
+ /**
51
+ * Build slide module registry using Vite's import.meta.glob()
52
+ * Uses the @slides alias which is resolved by each vite config:
53
+ * - Production courses (vite.config.js): @slides -> course/slides
54
+ * - Framework dev (vite.framework-dev.config.js): @slides -> template/course/slides
55
+ */
56
+ const slideModules = import.meta.glob('@slides/**/*.js');
57
+ const slideModuleRegistry = new Map();
58
+
59
+ for (const [globPath, loader] of Object.entries(slideModules)) {
60
+ // Normalize path to @slides/filename.js format
61
+ const aliasPath = globPath.startsWith('@slides/')
62
+ ? globPath
63
+ : '@slides/' + globPath.split('/slides/').pop();
64
+ slideModuleRegistry.set(aliasPath, loader);
65
+ }
66
+
67
+ /**
68
+ * Check if an element or any ancestor has data-lint-ignore.
69
+ * Supports category-specific suppression:
70
+ * data-lint-ignore → suppresses all rules
71
+ * data-lint-ignore="spacing" → suppresses only 'spacing' category
72
+ * data-lint-ignore="spacing,contrast" → suppresses multiple categories
73
+ *
74
+ * @param {HTMLElement} el - The element to check
75
+ * @param {string} [category] - Optional category to check against (e.g., 'spacing', 'contrast')
76
+ * @returns {boolean} True if lint warnings should be suppressed for this element
77
+ */
78
+ function isLintIgnored(el, category) {
79
+ let current = el;
80
+ while (current && current.nodeType === 1) {
81
+ if (current.hasAttribute('data-lint-ignore')) {
82
+ const value = current.getAttribute('data-lint-ignore');
83
+ // Empty or blank value = suppress all
84
+ if (!value || value.trim() === '') return true;
85
+ // Check if our category is in the comma-separated list
86
+ if (category) {
87
+ const categories = value.split(',').map(c => c.trim().toLowerCase());
88
+ if (categories.includes(category.toLowerCase())) return true;
89
+ } else {
90
+ // No category specified = always match a present attribute
91
+ return true;
92
+ }
93
+ }
94
+ current = current.parentElement;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Build audio file registry using Vite's import.meta.glob()
101
+ * Path is relative from framework/js/dev/ to course/assets/audio/
102
+ * - Production courses: ../../../course/assets/audio/
103
+ * - Framework dev: Vite alias maps this appropriately
104
+ *
105
+ * Audio files matching slide naming patterns (e.g., intro.mp3, ui-demo--modal.mp3)
106
+ * are typically outputs of narration generation and should be referenced in course-config.
107
+ */
108
+ const audioFiles = import.meta.glob('../../../course/assets/audio/**/*.mp3', { query: '?url', import: 'default' });
109
+ const audioFileRegistry = new Set();
110
+
111
+ for (const globPath of Object.keys(audioFiles)) {
112
+ // Extract just the filename from the path
113
+ const filename = globPath.split('/').pop();
114
+ audioFileRegistry.add(filename);
115
+ }
116
+
117
+ // ============================================================================
118
+ // Persistent offscreen container — stays in the DOM so getComputedStyle works,
119
+ // but is invisible and doesn't trigger visible layout recalculations.
120
+ // Created once, reused for every slide render during linting.
121
+ // ============================================================================
122
+ let offscreenContainer = null;
123
+
124
+ function getOffscreenContainer() {
125
+ if (!offscreenContainer) {
126
+ offscreenContainer = document.createElement('div');
127
+ offscreenContainer.id = '__lint-offscreen';
128
+ offscreenContainer.setAttribute('aria-hidden', 'true');
129
+ Object.assign(offscreenContainer.style, {
130
+ position: 'fixed',
131
+ left: '-20000px',
132
+ top: '0',
133
+ width: '1280px', // Standard desktop width for layout calculations
134
+ height: '720px',
135
+ overflow: 'hidden',
136
+ visibility: 'hidden',
137
+ pointerEvents: 'none'
138
+ });
139
+ document.body.appendChild(offscreenContainer);
140
+ }
141
+ return offscreenContainer;
142
+ }
143
+
144
+ function cleanupOffscreenContainer() {
145
+ if (offscreenContainer) {
146
+ offscreenContainer.remove();
147
+ offscreenContainer = null;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Lints the entire course configuration and structure.
153
+ * Validates that engagement requirements match the actual slide content.
154
+ *
155
+ * Architecture: Single-pass per slide with offscreen rendering and async chunking.
156
+ * Each slide is rendered once into an offscreen container. All checks (audio scan,
157
+ * engagement, visual layout, CSS classes) run against that single render. The browser
158
+ * yields between slides to prevent main thread lockup.
159
+ *
160
+ * @param {object} courseConfig - The course configuration object from course-config.js
161
+ * @throws {Error} If any validation errors are found
162
+ */
163
+ export async function lintCourse(courseConfig) {
164
+ const errors = [];
165
+ const warnings = [];
166
+ const interactionIdRegistry = new Map();
167
+
168
+ // Validate structure exists
169
+ if (!courseConfig || !courseConfig.structure) {
170
+ throw new Error('[RuntimeLinter] FATAL: courseConfig.structure is required');
171
+ }
172
+
173
+ // Flatten structure to get all slides (including nested in sections)
174
+ const slides = flattenStructure(courseConfig.structure);
175
+
176
+ // --- 1. Global Configuration Validation (no DOM needed) ---
177
+ const { warnings: globalWarnings, objectiveIds } = validateGlobalConfig(courseConfig, slides);
178
+ warnings.push(...globalWarnings);
179
+
180
+ // Build valid CSS class index from loaded stylesheets (CSSOM) once for all slides
181
+ const validCssClasses = buildCssClassIndex();
182
+
183
+ // Collect config-level audio references once (no DOM needed)
184
+ const referencedAudioFiles = collectReferencedAudioFiles(slides);
185
+
186
+ // --- 2. Per-slide validation (single render, all checks) ---
187
+ const layout = courseConfig.layout || 'article';
188
+ for (const slide of slides) {
189
+ await validateSlide(slide, objectiveIds, errors, warnings, interactionIdRegistry, validCssClasses, layout, referencedAudioFiles);
190
+
191
+ // Yield to the event loop between slides to keep the browser responsive
192
+ await new Promise(r => setTimeout(r, 0));
193
+ }
194
+
195
+ // --- 3. Post-slide global audio check ---
196
+ const allSlideIds = new Set(slides.map(s => s.id));
197
+ for (const audioFile of audioFileRegistry) {
198
+ const baseName = audioFile.replace('.mp3', '');
199
+ const slideIdMatch = baseName.split('--')[0];
200
+ if (allSlideIds.has(slideIdMatch) && !referencedAudioFiles.has(audioFile)) {
201
+ warnings.push(`Unused Narration Audio: "${audioFile}" matches slide "${slideIdMatch}" but is not referenced. Remove the file or ensure it's used via course-config audio or data-audio-src attributes.`);
202
+ }
203
+ }
204
+
205
+ // Cleanup offscreen container
206
+ cleanupOffscreenContainer();
207
+
208
+ // Display warnings individually so each appears as a separate entry in the debug panel
209
+ if (warnings.length > 0) {
210
+ for (const w of warnings) {
211
+ logger.warn(`COURSE VALIDATION: ${w}`, { domain: 'validation', operation: 'lint' });
212
+ }
213
+ }
214
+
215
+ // Report errors individually so each appears as a separate entry in the debug panel, then halt
216
+ if (errors.length > 0) {
217
+ for (const e of errors) {
218
+ logger.error(`COURSE VALIDATION: ${e}`, { domain: 'validation', operation: 'lint' });
219
+ }
220
+ throw new Error(`COURSE VALIDATION FAILED: ${errors.length} error(s) must be fixed. See individual errors above.`);
221
+ }
222
+ }
223
+
224
+ // flattenStructure and registerInteractionId are imported from validation-rules.js
225
+
226
+ /**
227
+ * Performs global validation across the entire course configuration.
228
+ * No DOM needed — pure config/structure checks.
229
+ */
230
+ function validateGlobalConfig(courseConfig, slides) {
231
+ const warnings = [];
232
+ const slideComponentPaths = new Set(slides.map(s => s.component));
233
+ const allObjectiveIds = new Set();
234
+
235
+ // 1. Check for orphaned slide files
236
+ for (const knownFile of slideModuleRegistry.keys()) {
237
+ if (!slideComponentPaths.has(knownFile)) {
238
+ warnings.push(`Orphaned File: Slide module "${knownFile}" exists but is not used in the course structure.`);
239
+ }
240
+ }
241
+
242
+ // 2. Validate objectives
243
+ if (courseConfig.objectives && Array.isArray(courseConfig.objectives)) {
244
+ const allSlideIds = new Set(slides.map(s => s.id));
245
+
246
+ for (const objective of courseConfig.objectives) {
247
+ if (!objective.id) {
248
+ warnings.push('Objective missing required \'id\' property.');
249
+ continue;
250
+ }
251
+ allObjectiveIds.add(objective.id);
252
+
253
+ if (objective.criteria) {
254
+ const criteria = objective.criteria;
255
+ if (criteria.type === 'slideVisited' && criteria.slideId && !allSlideIds.has(criteria.slideId)) {
256
+ warnings.push(`Objective "${objective.id}" has 'slideVisited' criteria with an invalid slideId: "${criteria.slideId}".`);
257
+ }
258
+ if (criteria.type === 'allSlidesVisited' && Array.isArray(criteria.slideIds)) {
259
+ for (const slideId of criteria.slideIds) {
260
+ if (!allSlideIds.has(slideId)) {
261
+ warnings.push(`Objective "${objective.id}" has 'allSlidesVisited' criteria with an invalid slideId: "${slideId}".`);
262
+ }
263
+ }
264
+ }
265
+ if (criteria.type === 'timeOnSlide' && criteria.slideId && !allSlideIds.has(criteria.slideId)) {
266
+ warnings.push(`Objective "${objective.id}" has 'timeOnSlide' criteria with an invalid slideId: "${criteria.slideId}".`);
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ return { warnings, objectiveIds: allObjectiveIds };
273
+ }
274
+
275
+ /**
276
+ * Collects all audio files referenced in course-config slide configurations.
277
+ * No DOM needed — reads from config objects only.
278
+ */
279
+ function collectReferencedAudioFiles(slides) {
280
+ const referenced = new Set();
281
+
282
+ for (const slide of slides) {
283
+ if (!slide.audio?.src) continue;
284
+
285
+ const src = slide.audio.src;
286
+ let audioFilename = null;
287
+
288
+ if (src.startsWith('@slides/')) {
289
+ const match = src.match(/@slides\/([^.]+)\.js(?:#(.+))?/);
290
+ if (match) {
291
+ const slideBase = match[1];
292
+ const key = match[2];
293
+ audioFilename = key ? `${slideBase}--${key}.mp3` : `${slideBase}.mp3`;
294
+ }
295
+ } else {
296
+ audioFilename = src.split('/').pop();
297
+ }
298
+
299
+ if (audioFilename) {
300
+ referenced.add(audioFilename);
301
+ }
302
+ }
303
+
304
+ return referenced;
305
+ }
306
+
307
+ /**
308
+ * Validates a single slide's configuration.
309
+ * Single render per slide: loads module, renders once to offscreen container,
310
+ * runs ALL checks (audio, engagement, visual, CSS), then cleans up.
311
+ */
312
+ async function validateSlide(slide, objectiveIds, errors, warnings, interactionIdRegistry, validCssClasses, layout, referencedAudioFiles) {
313
+ logger.debug(`[RuntimeLinter] Validating ${slide.id}...`);
314
+
315
+ // Check for engagement configuration
316
+ if (!slide.engagement) {
317
+ errors.push(`Slide "${slide.id}" (${slide.component}) is missing required 'engagement' configuration. Add "engagement: { required: false }" at minimum.`);
318
+ return;
319
+ }
320
+
321
+ const engagement = slide.engagement;
322
+ const isAssessment = slide.type === 'assessment';
323
+
324
+ if (isAssessment) {
325
+ logger.debug(`[RuntimeLinter] ${slide.id} is an assessment - validating config without DOM rendering`);
326
+
327
+ try {
328
+ const slideModule = await loadSlideModule(slide.component);
329
+
330
+ if (slideModule.config) {
331
+ if (slideModule.config.id && slideModule.config.id !== slide.id) {
332
+ errors.push(`Assessment ID mismatch: course-config.js declares slide id="${slide.id}" but ${slide.component} exports config.id="${slideModule.config.id}". These must match for proper SCORM tracking.`);
333
+ }
334
+
335
+ const assessmentConfig = extractCompleteAssessmentConfig(slideModule, slide.id);
336
+ if (assessmentConfig) {
337
+ validateAssessmentConfig(assessmentConfig, slide.id, objectiveIds, errors, warnings, interactionIdRegistry);
338
+ }
339
+ } else {
340
+ errors.push(`Slide "${slide.id}" is marked as type='assessment' but does not export a 'config' object.`);
341
+ }
342
+ } catch (error) {
343
+ errors.push(`Slide "${slide.id}" (assessment) failed to load: ${error.message}`);
344
+ }
345
+
346
+ return; // Skip DOM rendering and visual validation for assessments
347
+ }
348
+
349
+ // --- Single render for all DOM-based checks ---
350
+ try {
351
+ const slideModule = await loadSlideModule(slide.component);
352
+ const renderedContent = await renderSlideToDOM(slideModule);
353
+
354
+ // 1. Scan inline audio references (folded in from the deleted collectInlineAudioReferences)
355
+ const audioElements = renderedContent.querySelectorAll('[data-audio-src]');
356
+ for (const el of audioElements) {
357
+ const src = el.dataset.audioSrc;
358
+ if (src) {
359
+ referencedAudioFiles.add(src.split('/').pop());
360
+ }
361
+ }
362
+
363
+ // 2. Audio conflict check (slide audio vs modal/standalone audio)
364
+ if (slide.audio && slide.audio.src) {
365
+ const modalsWithAudio = renderedContent.querySelectorAll('[data-modal-trigger][data-audio-src], [data-component="modal-trigger"][data-audio-src]');
366
+ for (const modal of modalsWithAudio) {
367
+ const modalLabel = modal.textContent.trim().substring(0, 40);
368
+ errors.push(`Slide "${slide.id}" cannot have both slide audio and modal audio (constraint: singleton audio element). Remove audio from modal "${modalLabel}" or remove slide audio.`);
369
+ }
370
+
371
+ const standaloneAudioPlayers = renderedContent.querySelectorAll('[data-component="audio-player"]');
372
+ if (standaloneAudioPlayers.length > 0) {
373
+ errors.push(`Slide "${slide.id}" cannot have both slide audio and standalone audio players (constraint: singleton audio element). Remove data-component="audio-player" elements or remove slide audio.`);
374
+ }
375
+ }
376
+
377
+ // 3. Engagement validation
378
+ if (!engagement.required) {
379
+ logger.debug(`[RuntimeLinter] ${slide.id} has required=false, skipping engagement validation`);
380
+ } else {
381
+ if (!engagement.requirements || !Array.isArray(engagement.requirements)) {
382
+ errors.push(`Slide "${slide.id}" has engagement.required=true but no requirements array defined.`);
383
+ } else if (engagement.requirements.length === 0) {
384
+ warnings.push(`Slide "${slide.id}" has engagement.required=true but empty requirements array. Set required=false if no tracking needed.`);
385
+ } else {
386
+ if (engagement.mode && !['all', 'any'].includes(engagement.mode)) {
387
+ errors.push(`Slide "${slide.id}" has invalid engagement.mode "${engagement.mode}". Must be "all" or "any".`);
388
+ }
389
+
390
+ for (const req of engagement.requirements) {
391
+ validateRequirement(slide.id, req, renderedContent, errors, warnings);
392
+ }
393
+ }
394
+
395
+ const declarativeInteractions = renderedContent.querySelectorAll('[data-interaction-id]');
396
+ for (const interaction of declarativeInteractions) {
397
+ registerInteractionId(interaction.dataset.interactionId, slide.id, 'DOM Interaction', interactionIdRegistry, errors);
398
+ }
399
+ }
400
+
401
+ // 4. Gating conditions
402
+ if (slide.navigation?.gating) {
403
+ validateGatingConditions(slide.id, slide.navigation.gating, objectiveIds, errors);
404
+ }
405
+
406
+ // 5. Assessment config on non-assessment slides
407
+ if (slideModule.assessmentConfig || slideModule.config) {
408
+ const assessmentConfig = extractCompleteAssessmentConfig(slideModule, slide.id);
409
+ if (assessmentConfig) {
410
+ validateAssessmentConfig(assessmentConfig, slide.id, objectiveIds, errors, warnings, interactionIdRegistry);
411
+ }
412
+ }
413
+
414
+ // 6. Visual layout and CSS class validation (skip for canvas layout)
415
+ if (layout !== 'canvas') {
416
+ validateVisualLayout(slide.id, renderedContent, errors, warnings);
417
+ validateCssClasses(slide.id, renderedContent, validCssClasses, warnings);
418
+ validateButtonVariants(slide.id, renderedContent, warnings);
419
+ }
420
+
421
+ // 7. Modal audio patterns
422
+ validateModalAudioPatterns(slide.id, renderedContent, warnings);
423
+
424
+ // 8. Component structure
425
+ validateComponentStructure(slide.id, renderedContent, warnings);
426
+
427
+ // Cleanup — remove rendered content from offscreen container
428
+ renderedContent.remove();
429
+ } catch (error) {
430
+ errors.push(`Slide "${slide.id}" failed to load or render: ${error.message}`);
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Dynamically loads a slide module using the Vite glob registry.
436
+ * @param {string} componentPath - The component path (e.g., '@slides/intro-01.js')
437
+ * @returns {Promise<object>} The slide module
438
+ */
439
+ async function loadSlideModule(componentPath) {
440
+ const loader = slideModuleRegistry.get(componentPath);
441
+
442
+ if (!loader) {
443
+ throw new Error(`Slide module not found in registry: ${componentPath}. Available modules: ${Array.from(slideModuleRegistry.keys()).join(', ')}`);
444
+ }
445
+
446
+ try {
447
+ return await loader();
448
+ } catch (error) {
449
+ throw new Error(`Failed to load slide module ${componentPath}: ${error.message}`);
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Renders a slide module into the offscreen container.
455
+ * Uses the persistent offscreen container so getComputedStyle works without
456
+ * causing visible layout recalculations.
457
+ */
458
+ async function renderSlideToDOM(slideModule) {
459
+ if (!slideModule.slide || typeof slideModule.slide.render !== 'function') {
460
+ throw new Error('Slide module must export a "slide" object with a "render" function');
461
+ }
462
+
463
+ // Clear interaction registry before each render to prevent duplicate ID errors
464
+ interactionRegistry.clear();
465
+
466
+ const context = {};
467
+ const slideId = slideModule.slide.id || 'UNKNOWN_SLIDE';
468
+
469
+ let slideContainer;
470
+ try {
471
+ slideContainer = slideModule.slide.render(null, context);
472
+ } catch (err) {
473
+ if (err.message && err.message.includes('StateManager: Not initialized')) {
474
+ slideContainer = document.createElement('div');
475
+ slideContainer.innerHTML = '<p>Mock content for linting validation</p>';
476
+ } else {
477
+ throw new Error(`Slide "${slideId}" render() failed: ${err.message}`);
478
+ }
479
+ }
480
+
481
+ if (!slideContainer) {
482
+ throw new Error(`Slide "${slideId}" render() returned null/undefined. Must return a DOM element.`);
483
+ }
484
+
485
+ // Append to offscreen container (not document.body) — allows getComputedStyle
486
+ // without triggering visible layout recalculations
487
+ const container = getOffscreenContainer();
488
+ container.appendChild(slideContainer);
489
+
490
+ return slideContainer;
491
+ }
492
+
493
+ /**
494
+ * Extracts the complete assessment configuration from a slide module.
495
+ * Assessment slides define questions/questionBanks inside render() and merge with config,
496
+ * so we need to parse the render function to find the complete configuration.
497
+ *
498
+ * @param {object} slideModule - The imported slide module
499
+ * @param {string} slideId - The slide identifier for error messages
500
+ * @returns {object|null} Complete assessment config with questions/questionBanks, or null if extraction fails
501
+ */
502
+ function extractCompleteAssessmentConfig(slideModule, slideId) {
503
+ const baseConfig = slideModule.assessmentConfig || slideModule.config;
504
+ if (!baseConfig) return null;
505
+
506
+ // Check if questionBanks exist but don't have 'questions' arrays - this means they're defined in render()
507
+ let hasRuntimeQuestionBanks = false;
508
+ if (Array.isArray(baseConfig.questionBanks) && baseConfig.questionBanks.length > 0) {
509
+ // Check if any bank is missing the 'questions' array (just has id/selectCount template)
510
+ const hasBanksWithoutQuestions = baseConfig.questionBanks.some(bank =>
511
+ !bank.questions || bank.questions.length === 0
512
+ );
513
+
514
+ if (hasBanksWithoutQuestions) {
515
+ hasRuntimeQuestionBanks = true;
516
+ logger.debug(`[RuntimeLinter] ${slideId}: questionBanks defined without questions array - questions defined in render()`);
517
+ }
518
+ }
519
+
520
+ // Check if questions array is missing entirely - might be defined in render()
521
+ let hasRuntimeQuestions = false;
522
+ if (!baseConfig.questions && !baseConfig.questionBanks) {
523
+ // No questions or banks in config - check if render function likely defines them
524
+ hasRuntimeQuestions = true;
525
+ logger.debug(`[RuntimeLinter] ${slideId}: No questions/questionBanks in config - likely defined in render()`);
526
+ }
527
+
528
+ // If questions or questionBanks are defined in render and merged with config,
529
+ // we can trust that they exist at runtime even though they're not in the exported config
530
+ if (hasRuntimeQuestions || hasRuntimeQuestionBanks) {
531
+ logger.debug(`[RuntimeLinter] ${slideId}: Marking assessment for runtime question validation skip`);
532
+ // Return a synthetic config that indicates validation should be skipped for question content
533
+ return {
534
+ ...baseConfig,
535
+ _hasRuntimeQuestions: hasRuntimeQuestions,
536
+ _hasRuntimeQuestionBanks: hasRuntimeQuestionBanks
537
+ };
538
+ }
539
+
540
+ logger.debug(`[RuntimeLinter] ${slideId}: Questions/questionBanks found in config - will validate statically`);
541
+ // Otherwise return the base config (which should have questions/questionBanks already)
542
+ return baseConfig;
543
+ }
544
+
545
+ // validateAssessmentConfig and validateQuestionConfig are imported from validation-rules.js
546
+
547
+
548
+ /**
549
+ * Validates a single requirement against rendered slide content.
550
+ * @param {string} slideId - The slide identifier
551
+ * @param {object} requirement - The requirement configuration
552
+ * @param {HTMLElement} renderedContent - The rendered slide DOM
553
+ * @param {array} errors - Array to collect errors
554
+ * @param {array} warnings - Array to collect warnings
555
+ */
556
+ function validateRequirement(slideId, requirement, renderedContent, errors, _warnings) {
557
+ const type = requirement.type;
558
+
559
+ // Schema-driven: build reverse map from engagementTracking -> componentType
560
+ const registeredTypes = getRegisteredComponentTypes();
561
+ for (const componentType of registeredTypes) {
562
+ const meta = getComponentMetadata(componentType);
563
+ if (meta?.engagementTracking === type) {
564
+ // This is a component-linked requirement — check DOM for component
565
+ const component = renderedContent.querySelector(`[data-component="${componentType}"]`);
566
+ if (!component) {
567
+ errors.push(`Slide "${slideId}" has '${type}' requirement but no ${componentType} component found. Add data-component="${componentType}" or remove this requirement.`);
568
+ return;
569
+ }
570
+ // Component exists — schema.structure children are validated by validateComponentStructure
571
+ return;
572
+ }
573
+ }
574
+
575
+ // Non-component requirement types — validate config properties
576
+ switch (type) {
577
+ case 'interactionComplete': {
578
+ if (!requirement.interactionId) {
579
+ errors.push(`Slide "${slideId}" has 'interactionComplete' requirement without interactionId. Add interactionId property.`);
580
+ return;
581
+ }
582
+ const interaction = renderedContent.querySelector(`[data-interaction-id="${requirement.interactionId}"]`);
583
+ if (!interaction) {
584
+ errors.push(`Slide "${slideId}" requires interaction "${requirement.interactionId}" but it doesn't exist in the rendered content. Check the interactionId.`);
585
+ }
586
+ break;
587
+ }
588
+
589
+ case 'allInteractionsComplete': {
590
+ const interactions = renderedContent.querySelectorAll('[data-interaction-id]');
591
+ if (interactions.length === 0) {
592
+ errors.push(`Slide "${slideId}" has 'allInteractionsComplete' requirement but no interactions found. Add interactions or remove this requirement.`);
593
+ }
594
+ break;
595
+ }
596
+
597
+ case 'scrollDepth': {
598
+ if (!requirement.percentage && !requirement.minPercentage) {
599
+ errors.push(`Slide "${slideId}" has 'scrollDepth' requirement without percentage or minPercentage property.`);
600
+ }
601
+ const percentage = requirement.percentage || requirement.minPercentage;
602
+ if (percentage < 0 || percentage > 100) {
603
+ errors.push(`Slide "${slideId}" scrollDepth percentage must be between 0-100 (got ${percentage}).`);
604
+ }
605
+ break;
606
+ }
607
+
608
+ case 'timeOnSlide': {
609
+ if (!requirement.minSeconds) {
610
+ errors.push(`Slide "${slideId}" has 'timeOnSlide' requirement without minSeconds property.`);
611
+ }
612
+ if (requirement.minSeconds < 0) {
613
+ errors.push(`Slide "${slideId}" timeOnSlide minSeconds must be positive (got ${requirement.minSeconds}).`);
614
+ }
615
+ break;
616
+ }
617
+
618
+ case 'flag': {
619
+ if (!requirement.key) {
620
+ errors.push(`Slide "${slideId}" has 'flag' requirement without key property.`);
621
+ }
622
+ break;
623
+ }
624
+
625
+ case 'allFlags': {
626
+ if (!requirement.flags || !Array.isArray(requirement.flags)) {
627
+ errors.push(`Slide "${slideId}" has 'allFlags' requirement without flags array.`);
628
+ return;
629
+ }
630
+ if (requirement.flags.length === 0) {
631
+ errors.push(`Slide "${slideId}" has 'allFlags' requirement with empty flags array.`);
632
+ }
633
+ break;
634
+ }
635
+
636
+ case 'slideAudioComplete':
637
+ break;
638
+
639
+ case 'audioComplete': {
640
+ if (!requirement.audioId) {
641
+ errors.push(`Slide "${slideId}" has 'audioComplete' requirement without audioId property.`);
642
+ }
643
+ break;
644
+ }
645
+
646
+ case 'modalAudioComplete': {
647
+ if (!requirement.modalId) {
648
+ errors.push(`Slide "${slideId}" has 'modalAudioComplete' requirement without modalId property.`);
649
+ }
650
+ break;
651
+ }
652
+
653
+ default:
654
+ errors.push(`Slide "${slideId}" has unknown requirement type: "${type}".`);
655
+ }
656
+ }
657
+
658
+
659
+ /**
660
+ * Parses an RGB or RGBA color string into an array of [r, g, b, a] values.
661
+ * @param {string} rgbString - e.g., "rgb(255, 255, 255)" or "rgba(255, 255, 255, 0.5)"
662
+ * @returns {array|null} - [r, g, b, a] or null if parse fails. Alpha defaults to 1.
663
+ */
664
+ function parseRgba(rgbString) {
665
+ if (!rgbString) return null;
666
+ const match = rgbString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/);
667
+ if (!match) return null;
668
+ return [
669
+ parseInt(match[1]),
670
+ parseInt(match[2]),
671
+ parseInt(match[3]),
672
+ match[4] ? parseFloat(match[4]) : 1
673
+ ];
674
+ }
675
+
676
+ /**
677
+ * Composites a semi-transparent foreground color over a background color.
678
+ * @param {array} fg - [r, g, b, a] foreground color
679
+ * @param {array} bg - [r, g, b, a] background color
680
+ * @returns {array} - [r, g, b] composited color
681
+ */
682
+ function compositeColors(fg, bg) {
683
+ const alpha = fg[3];
684
+ return [
685
+ Math.round(fg[0] * alpha + bg[0] * (1 - alpha)),
686
+ Math.round(fg[1] * alpha + bg[1] * (1 - alpha)),
687
+ Math.round(fg[2] * alpha + bg[2] * (1 - alpha))
688
+ ];
689
+ }
690
+
691
+ /**
692
+ * Calculates the relative luminance of an RGB color.
693
+ * Formula from WCAG guidelines.
694
+ * @param {array} rgb - [r, g, b]
695
+ * @returns {number} - Luminance value from 0 to 1.
696
+ */
697
+ function getLuminance(rgb) {
698
+ const [r, g, b] = rgb.map(c => {
699
+ const s = c / 255;
700
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
701
+ });
702
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
703
+ }
704
+
705
+ /**
706
+ * Calculates the contrast ratio between two RGB colors.
707
+ * @param {array} rgb1
708
+ * @param {array} rgb2
709
+ * @returns {number} - The contrast ratio.
710
+ */
711
+ function getContrastRatio(rgb1, rgb2) {
712
+ const lum1 = getLuminance(rgb1);
713
+ const lum2 = getLuminance(rgb2);
714
+ const lighter = Math.max(lum1, lum2);
715
+ const darker = Math.min(lum1, lum2);
716
+ return (lighter + 0.05) / (darker + 0.05);
717
+ }
718
+
719
+ /**
720
+ * Traverses up the DOM tree and composites all semi-transparent backgrounds
721
+ * to calculate the effective background color.
722
+ * @param {HTMLElement} element
723
+ * @returns {array} - [r, g, b] effective background color
724
+ */
725
+ function getEffectiveBackgroundColor(element) {
726
+ const layers = [];
727
+ let current = element;
728
+
729
+ // Collect all background colors up the tree
730
+ while (current && current.tagName !== 'HTML') {
731
+ const style = window.getComputedStyle(current);
732
+ const bgColor = style.backgroundColor;
733
+
734
+ if (bgColor && bgColor !== 'transparent') {
735
+ const rgba = parseRgba(bgColor);
736
+ if (rgba && rgba[3] > 0) {
737
+ layers.push(rgba);
738
+ // If we hit a fully opaque layer, we can stop
739
+ if (rgba[3] === 1) break;
740
+ }
741
+ }
742
+
743
+ current = current.parentElement;
744
+ }
745
+
746
+ // If no backgrounds found, assume white
747
+ if (layers.length === 0) {
748
+ return [255, 255, 255];
749
+ }
750
+
751
+ // Composite all layers from back to front
752
+ let result = layers[layers.length - 1];
753
+
754
+ // If the bottom layer isn't fully opaque, composite it over white
755
+ if (result[3] < 1) {
756
+ result = [...compositeColors(result, [255, 255, 255, 1]), 1];
757
+ }
758
+
759
+ // Composite remaining layers front to back
760
+ for (let i = layers.length - 2; i >= 0; i--) {
761
+ result = [...compositeColors(layers[i], result), 1];
762
+ }
763
+
764
+ return [result[0], result[1], result[2]];
765
+ }
766
+
767
+
768
+ /**
769
+ * Builds a compact description of an element and its ancestry for lint messages.
770
+ * Shows the element's tag + classes and the nearest meaningful ancestor's tag + classes.
771
+ * @param {HTMLElement} el - The element to describe
772
+ * @returns {string} Multiline context string, e.g. "Element: <h2 class=\"text-white mb-2\">\n Parent: <div class=\"hero-gradient p-6\">"
773
+ */
774
+ function getElementClassContext(el) {
775
+ const describeEl = (element) => {
776
+ const tag = element.tagName.toLowerCase();
777
+ const classes = Array.from(element.classList).join(' ');
778
+ return classes ? `<${tag} class="${classes}">` : `<${tag}>`;
779
+ };
780
+
781
+ const lines = [`Element: ${describeEl(el)}`];
782
+
783
+ // Walk up to find the first ancestor with CSS classes (skip classless wrappers)
784
+ let parent = el.parentElement;
785
+ let depth = 0;
786
+ while (parent && depth < 5) {
787
+ if (parent.classList.length > 0 && parent.tagName !== 'HTML' && parent.tagName !== 'BODY') {
788
+ lines.push(`Parent: ${describeEl(parent)}`);
789
+ break;
790
+ }
791
+ parent = parent.parentElement;
792
+ depth++;
793
+ }
794
+
795
+ return lines.join('\n ');
796
+ }
797
+
798
+ /**
799
+ * Validates visual layout and accessibility issues in rendered slide content.
800
+ * Catches common mistakes like nested cards, missing alt text, etc.
801
+ * @param {string} slideId - The slide identifier
802
+ * @param {HTMLElement} renderedContent - The rendered slide DOM
803
+ * @param {array} errors - Array to collect errors
804
+ * @param {array} warnings - Array to collect warnings
805
+ */
806
+ function validateVisualLayout(slideId, renderedContent, errors, warnings) {
807
+ const MIN_CONTRAST_RATIO_AA = 4.5;
808
+ const MIN_CONTRAST_RATIO_LARGE_AA = 3;
809
+ const MIN_FONT_SIZE_PX = 14;
810
+ const MIN_TARGET_SIZE_PX = 32; // Relaxed from 44px based on user feedback
811
+
812
+ try {
813
+ // --- NEW: ACCESSIBILITY & VISUAL CHECKS ---
814
+
815
+ // Check 1: Text legibility (font size and color contrast)
816
+ const textElements = renderedContent.querySelectorAll('p, span:not(.accordion-icon), li, a, h1, h2, h3, h4, h5, h6, button');
817
+ for (const el of textElements) {
818
+ if (isLintIgnored(el, 'contrast')) continue;
819
+ // Skip elements that are not visible or have no text
820
+ if (el.offsetParent === null || el.textContent.trim() === '') continue;
821
+
822
+ // Skip disabled buttons (framework handles contrast for these)
823
+ if (el.tagName === 'BUTTON' && el.hasAttribute('disabled')) continue;
824
+
825
+ // Skip elements that only contain emojis or special characters
826
+ const textContent = el.textContent.trim();
827
+ if (/^[\u{1F300}-\u{1F9FF}\s]*$/u.test(textContent)) continue;
828
+
829
+ // Skip if this element's text is entirely contained in child elements
830
+ // (to avoid duplicate checking of parent containers)
831
+ const childTextLength = Array.from(el.children).reduce((sum, child) =>
832
+ sum + child.textContent.length, 0);
833
+ if (childTextLength > textContent.length * 0.9) continue;
834
+
835
+ const style = window.getComputedStyle(el);
836
+ const fontSize = parseInt(style.fontSize, 10);
837
+ const fontWeight = parseInt(style.fontWeight, 10) || 400;
838
+
839
+ // Font size check
840
+ // Skip elements using intentional small text classes from the design system
841
+ // Also skip if font size matches design system values (12px = text-xs, 14px = text-sm)
842
+ const hasIntentionalSmallText = el.classList.contains('text-xs') ||
843
+ el.classList.contains('text-sm') ||
844
+ el.closest('.text-xs') !== null ||
845
+ el.closest('.text-sm') !== null ||
846
+ el.closest('.step-number') !== null ||
847
+ el.closest('.step') !== null ||
848
+ fontSize === 12; // Matches text-xs (0.75rem = 12px)
849
+ if (fontSize < MIN_FONT_SIZE_PX && !hasIntentionalSmallText) {
850
+ warnings.push(`Slide "${slideId}": Text with font size ${fontSize}px is smaller than the recommended minimum of ${MIN_FONT_SIZE_PX}px. Text: "${textContent.substring(0, 30)}..."\n ${getElementClassContext(el)}`);
851
+ }
852
+
853
+ // Color contrast check (CSS visual issue - warnings only, does not block)
854
+ // Skip badges - they use intentional design system colors
855
+ if (el.classList.contains('badge') || el.closest('.badge') !== null) continue;
856
+
857
+ // Skip contrast check for elements on gradient backgrounds (can't reliably compute)
858
+ const hasGradientBackground = el.closest('.gradient') !== null ||
859
+ el.closest('.gradient-light') !== null ||
860
+ el.closest('.hero-gradient') !== null ||
861
+ el.closest('.btn-gradient') !== null ||
862
+ el.closest('[class*="bg-gradient-dark"]') !== null ||
863
+ el.closest('[class*="gradient-header"]') !== null ||
864
+ el.closest('[class*="gradient-success"]') !== null ||
865
+ el.closest('[class*="gradient-progress"]') !== null ||
866
+ el.closest('[style*="linear-gradient"]') !== null ||
867
+ el.closest('[style*="radial-gradient"]') !== null ||
868
+ style.backgroundImage.includes('gradient');
869
+ if (hasGradientBackground) continue;
870
+
871
+ const textColorStr = style.color;
872
+ const bgColorRgb = getEffectiveBackgroundColor(el);
873
+ const textColorRgba = parseRgba(textColorStr);
874
+
875
+ if (textColorRgba && bgColorRgb) {
876
+ // If text color has transparency, composite it over the background
877
+ const textColorRgb = textColorRgba[3] < 1
878
+ ? compositeColors(textColorRgba, [...bgColorRgb, 1])
879
+ : [textColorRgba[0], textColorRgba[1], textColorRgba[2]];
880
+
881
+ const ratio = getContrastRatio(textColorRgb, bgColorRgb);
882
+ const isLargeText = fontSize >= 24 || (fontSize >= 18.66 && fontWeight >= 700);
883
+ const minRatio = isLargeText ? MIN_CONTRAST_RATIO_LARGE_AA : MIN_CONTRAST_RATIO_AA;
884
+
885
+ if (ratio < minRatio) {
886
+ const colorInfo = `Colors: text ${textColorStr} on bg rgb(${bgColorRgb.join(',')})`;
887
+ warnings.push(`Slide "${slideId}": Poor color contrast (${ratio.toFixed(2)}:1) for text "${textContent.substring(0, 30)}...". Must be at least ${minRatio}:1.\n ${getElementClassContext(el)}\n ${colorInfo}`);
888
+ }
889
+ }
890
+ }
891
+
892
+ // Check 2: Minimum target size for interactive elements
893
+ const interactiveElements = renderedContent.querySelectorAll('a, button, [role="button"], [data-interaction-id]');
894
+ for (const el of interactiveElements) {
895
+ if (el.offsetParent === null) continue; // Skip hidden elements
896
+ if (isLintIgnored(el, 'target-size')) continue;
897
+
898
+ // Skip elements with intentionally small sizes (demo purposes, decorative, etc.)
899
+ if (el.classList.contains('btn-sm') || el.classList.contains('btn-disabled')) continue;
900
+ if (el.hasAttribute('disabled')) continue;
901
+
902
+ // Skip links that are in lists or code blocks (documentation/reference contexts)
903
+ if (el.tagName === 'A' && el.closest('li')) continue;
904
+ if (el.tagName === 'A' && el.closest('code')) continue;
905
+
906
+ const rect = el.getBoundingClientRect();
907
+ if (rect.width > 0 && rect.height > 0 && (rect.width < MIN_TARGET_SIZE_PX || rect.height < MIN_TARGET_SIZE_PX)) {
908
+ warnings.push(`Slide "${slideId}": Interactive element (${Math.round(rect.width)}x${Math.round(rect.height)}px) is smaller than ${MIN_TARGET_SIZE_PX}x${MIN_TARGET_SIZE_PX}px. Text: "${el.textContent.trim().substring(0, 30)}..."\n ${getElementClassContext(el)}`);
909
+ }
910
+ }
911
+
912
+ // --- ORIGINAL CHECKS (RETAINED & IMPROVED) ---
913
+
914
+ // Check 3: Images without alt text (WCAG violation)
915
+ const images = renderedContent.querySelectorAll('img');
916
+ for (const img of images) {
917
+ if (!img.hasAttribute('alt')) {
918
+ errors.push(`Slide "${slideId}": Image missing alt attribute: ${img.src || 'unknown source'}`);
919
+ } else if (img.getAttribute('alt') === '') {
920
+ warnings.push(`Slide "${slideId}": Image has empty alt text (only use for decorative images): ${img.src || 'unknown source'}`);
921
+ }
922
+ }
923
+
924
+ // Check 4: Empty headings
925
+ const headings = renderedContent.querySelectorAll('h1, h2, h3, h4, h5, h6');
926
+ for (const heading of headings) {
927
+ if (heading.textContent.trim() === '') {
928
+ errors.push(`Slide "${slideId}": Empty ${heading.tagName} found - headings must have text content.`);
929
+ }
930
+ }
931
+
932
+ // Check 6: Very long lists (UX issue)
933
+ const lists = renderedContent.querySelectorAll('ul, ol');
934
+ for (const list of lists) {
935
+ const items = list.querySelectorAll(':scope > li');
936
+ if (items.length > 8) {
937
+ warnings.push(`Slide "${slideId}": List with ${items.length} items - consider using accordion or breaking into sections.`);
938
+ }
939
+ }
940
+
941
+ // Check 7: Buttons without .btn class (but not framework component buttons)
942
+ const buttons = renderedContent.querySelectorAll('button:not([data-component]):not(.btn)');
943
+ for (const btn of buttons) {
944
+ // Skip buttons that are inside framework components (tabs, accordions, interactions)
945
+ const isInsideComponent = btn.closest('[data-component]') || btn.closest('[data-interaction-id]');
946
+ if (isInsideComponent) continue;
947
+
948
+ // Skip buttons that are part of framework UI (accordion icons, tab controls, etc.)
949
+ if (btn.classList.contains('accordion-button') || btn.classList.contains('tab-button')) continue;
950
+
951
+ const buttonText = btn.textContent.trim().substring(0, 30);
952
+ warnings.push(`Slide "${slideId}": Button missing .btn class: "${buttonText}${buttonText.length >= 30 ? '...' : ''}"`);
953
+ }
954
+
955
+ // Check 8: Multiple h1 tags (SEO/accessibility issue)
956
+ const h1s = renderedContent.querySelectorAll('h1');
957
+ if (h1s.length > 1) {
958
+ errors.push(`Slide "${slideId}": Found ${h1s.length} h1 tags - use only one per slide.`);
959
+ }
960
+
961
+ // Check 9: Links without proper attributes
962
+ // Skip lightbox triggers and media component links - they open in overlays, not new tabs
963
+ const externalLinks = renderedContent.querySelectorAll('a[href^="http"]');
964
+ for (const link of externalLinks) {
965
+ // Lightbox triggers open media in an overlay, not a new tab
966
+ const isLightboxTrigger = link.dataset.component === 'lightbox';
967
+ if (isLightboxTrigger) continue;
968
+
969
+ // Links inside media components (video-player, carousel) are data sources, not navigation
970
+ const isInsideMediaComponent = link.closest('[data-component="video-player"]') ||
971
+ link.closest('[data-component="carousel"]');
972
+ if (isInsideMediaComponent) continue;
973
+
974
+ if (!link.hasAttribute('target')) {
975
+ warnings.push(`Slide "${slideId}": External link missing target="_blank": ${link.href.substring(0, 50)}`);
976
+ }
977
+ if (!link.getAttribute('rel') || !link.getAttribute('rel').includes('noopener')) {
978
+ warnings.push(`Slide "${slideId}": External link missing rel="noopener noreferrer": ${link.href.substring(0, 50)}`);
979
+ }
980
+ }
981
+
982
+ // Check 10: Text too close to visual elements (borders, shadows, backgrounds)
983
+ validateTextProximityToVisualElements(slideId, renderedContent, warnings);
984
+
985
+ // Check 11: Element overlap and visual collision detection
986
+ validateElementOverlap(slideId, renderedContent, warnings);
987
+
988
+ // Check 13: Element spacing — missing gaps in flex/grid, zero-margin siblings, unpadded containers
989
+ validateElementSpacing(slideId, renderedContent, warnings);
990
+
991
+ // Check 14: Content overflow — content exceeding its container
992
+ validateContentOverflow(slideId, renderedContent, warnings);
993
+
994
+ // Check 12: Styled lists validation
995
+ validateStyledLists(slideId, renderedContent, warnings);
996
+
997
+ } catch (error) {
998
+ logger.error(`[RuntimeLinter] Visual validation error for slide "${slideId}":`, error);
999
+ // Don't add to errors array - visual validation failures shouldn't block course loading
1000
+ }
1001
+ }
1002
+
1003
+ /**
1004
+ * Validates that text elements have sufficient padding/spacing from visual borders and visual elements.
1005
+ * Detects text that may appear too close to borders (any side), box-shadows, or background edges.
1006
+ * @param {string} slideId - The slide identifier
1007
+ * @param {HTMLElement} renderedContent - The rendered slide DOM
1008
+ * @param {array} warnings - Array to collect warnings
1009
+ */
1010
+ function validateTextProximityToVisualElements(slideId, renderedContent, warnings) {
1011
+ const MIN_PADDING_THICK_BORDER_PX = 12; // Minimum padding for thick borders (>2px)
1012
+ const MIN_PADDING_THIN_BORDER_PX = 4; // Minimum padding for hairline borders (≤2px)
1013
+ const MIN_PADDING_SHADOW_PX = 8; // Minimum padding from box-shadow edges
1014
+ const THICK_BORDER_THRESHOLD = 2; // Borders >2px are considered "thick/structural"
1015
+
1016
+ // Framework components that manage their own internal spacing (exclude from checks)
1017
+ // These are EXACT class names that should be excluded
1018
+ const FRAMEWORK_COMPONENT_EXACT = new Set([
1019
+ 'accordion', 'accordion-item', 'accordion-header', 'accordion-content',
1020
+ 'tab-button', 'tab-content', 'tab-list', 'content-tabs', 'assessment-tabs',
1021
+ 'card', 'card-header', 'card-body', 'card-footer',
1022
+ 'modal', 'modal-content', 'modal-header', 'modal-body', 'modal-footer',
1023
+ 'callout', 'alert', 'notification',
1024
+ 'carousel', 'carousel-item',
1025
+ 'dropdown', 'dropdown-menu', 'dropdown-item',
1026
+ 'table', // Tables manage their own cell spacing
1027
+ 'step-number', 'step-content', 'step', // Pattern-steps elements have intentional circular styling
1028
+ 'btn-link' // Link-styled button intentionally has minimal padding
1029
+ ]);
1030
+
1031
+ // HTML elements that manage their own spacing (exclude from checks)
1032
+ const FRAMEWORK_ELEMENT_TAGS = new Set(['THEAD', 'TBODY', 'TR', 'TH', 'TD', 'TABLE']);
1033
+
1034
+ // Helper: Check if element is a semantic container with block-level children
1035
+ // Containers are excluded from border/padding validation because they manage spacing for children
1036
+ const isSemanticContainer = (el) => {
1037
+ // Block-level elements that provide their own spacing
1038
+ const blockLevelTags = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P', 'UL', 'OL', 'DIV', 'SECTION', 'ARTICLE', 'BUTTON', 'A']);
1039
+
1040
+ // Get direct children (not all descendants)
1041
+ const children = Array.from(el.children);
1042
+
1043
+ // If no children, it's not a container
1044
+ if (children.length === 0) return false;
1045
+
1046
+ // Container-style patterns: Has multiple children of consistent type OR is known container type
1047
+ const isContainerType = ['DIV', 'SECTION', 'ARTICLE', 'UL', 'OL'].includes(el.tagName);
1048
+ const hasMultipleChildren = children.length >= 2;
1049
+
1050
+ // Check if all children are block-level or interactive elements
1051
+ const allChildrenAreBlockLevel = children.every(child => blockLevelTags.has(child.tagName));
1052
+
1053
+ // Check if element has minimal direct text (only whitespace/formatting)
1054
+ // Containers pass text to children, so they shouldn't have direct text
1055
+ const directText = Array.from(el.childNodes)
1056
+ .filter(node => node.nodeType === Node.TEXT_NODE)
1057
+ .map(node => node.textContent.trim())
1058
+ .join('');
1059
+ const hasMinimalDirectText = directText.length < 10;
1060
+
1061
+ // It's a container if: it looks like one AND doesn't hold direct text
1062
+ return isContainerType && (allChildrenAreBlockLevel || hasMultipleChildren) && hasMinimalDirectText;
1063
+ };
1064
+
1065
+ // Helper: Get effective spacing for child elements
1066
+ const getChildMargin = (el, side) => {
1067
+ const children = Array.from(el.children);
1068
+ if (children.length === 0) return 0;
1069
+
1070
+ const firstChild = children[0];
1071
+ const firstChildStyle = window.getComputedStyle(firstChild);
1072
+
1073
+ switch (side) {
1074
+ case 'left': return parseFloat(firstChildStyle.marginLeft);
1075
+ case 'right': return parseFloat(firstChildStyle.marginRight);
1076
+ case 'top': return parseFloat(firstChildStyle.marginTop);
1077
+ case 'bottom': return parseFloat(firstChildStyle.marginBottom);
1078
+ default: return 0;
1079
+ }
1080
+ };
1081
+
1082
+ // Single pass: check ALL elements for borders AND box-shadows
1083
+ const allElements = renderedContent.querySelectorAll('*');
1084
+ for (const el of allElements) {
1085
+ if (el.offsetParent === null) continue;
1086
+ if (isLintIgnored(el, 'proximity')) continue;
1087
+ if (el.textContent.trim() === '') continue;
1088
+
1089
+ // Skip framework components that manage their own spacing
1090
+ const classes = el.className.toString().split(' ');
1091
+ if (classes.some(cls => FRAMEWORK_COMPONENT_EXACT.has(cls))) continue;
1092
+
1093
+ // Skip HTML elements that manage their own spacing (tables, etc.)
1094
+ if (FRAMEWORK_ELEMENT_TAGS.has(el.tagName)) continue;
1095
+
1096
+ const style = window.getComputedStyle(el);
1097
+ const elementDesc = el.className ? `.${el.className.split(' ')[0]}` : el.tagName.toLowerCase();
1098
+
1099
+ // --- Border proximity checks ---
1100
+ const isSemantic = isSemanticContainer(el);
1101
+
1102
+ const checkBorder = (side, borderWidth, padding) => {
1103
+ if (borderWidth > 0) {
1104
+ const isThickBorder = borderWidth > THICK_BORDER_THRESHOLD;
1105
+ const minPadding = isThickBorder ? MIN_PADDING_THICK_BORDER_PX : MIN_PADDING_THIN_BORDER_PX;
1106
+
1107
+ let effectiveSpacing = padding;
1108
+ if (isSemantic) {
1109
+ const childMargin = getChildMargin(el, side);
1110
+ effectiveSpacing = Math.max(padding, childMargin);
1111
+ if (effectiveSpacing >= minPadding) return;
1112
+ }
1113
+
1114
+ if (effectiveSpacing < minPadding) {
1115
+ const textPreview = el.textContent.trim().substring(0, 40);
1116
+ const borderType = isThickBorder ? 'thick' : 'hairline';
1117
+ warnings.push(`Slide "${slideId}": Element (${elementDesc}) with ${borderType} ${side} border (${borderWidth.toFixed(1)}px) has insufficient ${side} ${isSemantic ? 'spacing' : 'padding'} (${effectiveSpacing.toFixed(0)}px, need ≥${minPadding}px). Content: "${textPreview}..."\n ${getElementClassContext(el)}`);
1118
+ }
1119
+ }
1120
+ };
1121
+
1122
+ checkBorder('left', parseFloat(style.borderLeftWidth), parseFloat(style.paddingLeft));
1123
+ checkBorder('right', parseFloat(style.borderRightWidth), parseFloat(style.paddingRight));
1124
+ checkBorder('top', parseFloat(style.borderTopWidth), parseFloat(style.paddingTop));
1125
+ checkBorder('bottom', parseFloat(style.borderBottomWidth), parseFloat(style.paddingBottom));
1126
+
1127
+ // --- Box-shadow proximity check ---
1128
+ const boxShadow = style.boxShadow;
1129
+ if (boxShadow && boxShadow !== 'none') {
1130
+ const padding = Math.min(
1131
+ parseFloat(style.paddingLeft),
1132
+ parseFloat(style.paddingRight),
1133
+ parseFloat(style.paddingTop),
1134
+ parseFloat(style.paddingBottom)
1135
+ );
1136
+
1137
+ if (padding < MIN_PADDING_SHADOW_PX) {
1138
+ const textPreview = el.textContent.trim().substring(0, 40);
1139
+ warnings.push(`Slide "${slideId}": Element (${elementDesc}) with box-shadow has minimal internal padding (${padding}px, recommended ≥8px for visual breathing room). Content: "${textPreview}..."\n ${getElementClassContext(el)}`);
1140
+ }
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ /**
1146
+ * Validates for element overlap and visual collisions.
1147
+ * Detects absolutely positioned elements, floating elements, or z-index layering issues.
1148
+ * @param {string} slideId - The slide identifier
1149
+ * @param {HTMLElement} renderedContent - The rendered slide DOM
1150
+ * @param {array} warnings - Array to collect warnings
1151
+ */
1152
+ function validateElementOverlap(slideId, renderedContent, warnings) {
1153
+ // Check for absolutely positioned elements that might overlap
1154
+ const absoluteElements = renderedContent.querySelectorAll('[style*="position:absolute"], [style*="position: absolute"]');
1155
+ for (const el of absoluteElements) {
1156
+ if (el.offsetParent === null) continue;
1157
+
1158
+ const rect = el.getBoundingClientRect();
1159
+ const elText = el.textContent.trim().substring(0, 30);
1160
+
1161
+ // Check against all other visible elements
1162
+ const allElements = renderedContent.querySelectorAll('*');
1163
+ for (const other of allElements) {
1164
+ if (other === el || other.offsetParent === null) continue;
1165
+
1166
+ const otherRect = other.getBoundingClientRect();
1167
+
1168
+ // Simple AABB collision detection
1169
+ if (rect.right > otherRect.left && rect.left < otherRect.right &&
1170
+ rect.bottom > otherRect.top && rect.top < otherRect.bottom) {
1171
+
1172
+ // Skip if one element is a child of the other
1173
+ if (el.contains(other) || other.contains(el)) continue;
1174
+
1175
+ warnings.push(`Slide "${slideId}": Possible visual overlap detected - absolutely positioned element ("${elText}...") may overlap other content. Check z-index layering.`);
1176
+ break; // Only warn once per element
1177
+ }
1178
+ }
1179
+ }
1180
+
1181
+ // Check for floating elements
1182
+ const floatElements = renderedContent.querySelectorAll('[style*="float:left"], [style*="float:right"], [style*="float: left"], [style*="float: right"]');
1183
+ if (floatElements.length > 1) {
1184
+ warnings.push(`Slide "${slideId}": Found ${floatElements.length} floating elements - be cautious of layout shifts and overlaps. Consider using flexbox/grid instead.`);
1185
+ }
1186
+
1187
+ // Check for high z-index values that might cause layering issues
1188
+ const zIndexElements = renderedContent.querySelectorAll('[style*="z-index"]');
1189
+ const highZIndices = Array.from(zIndexElements)
1190
+ .map(el => ({
1191
+ el,
1192
+ zIndex: parseInt(window.getComputedStyle(el).zIndex || 0)
1193
+ }))
1194
+ .filter(({ zIndex }) => zIndex > 1000);
1195
+
1196
+ if (highZIndices.length > 2) {
1197
+ warnings.push(`Slide "${slideId}": Multiple elements with high z-index (>1000) detected - complex layering can cause accessibility and interaction issues.`);
1198
+ }
1199
+ }
1200
+
1201
+ /**
1202
+ * Validates that lists with 3+ items in main content are using styled list classes.
1203
+ * Enforces consistent visual styling for better readability and hierarchy.
1204
+ *
1205
+ * Rules:
1206
+ * - Main content lists with 3+ items MUST use .list-styled (unordered) or .list-numbered (ordered)
1207
+ * - Nested lists (inside accordions, collapsibles) are OPTIONAL (warns only)
1208
+ * - Intentional unstyled lists (inside code blocks, special formatting) can be ignored
1209
+ *
1210
+ * @param {string} slideId - The slide identifier
1211
+ * @param {HTMLElement} renderedContent - The rendered slide DOM
1212
+ * @param {array} warnings - Array to collect warnings
1213
+ */
1214
+ function validateStyledLists(slideId, renderedContent, warnings) {
1215
+ // Find all lists
1216
+ const lists = renderedContent.querySelectorAll('ul, ol');
1217
+
1218
+ for (const list of lists) {
1219
+ // Count direct children list items
1220
+ const items = list.querySelectorAll(':scope > li');
1221
+
1222
+ // Skip if fewer than 3 items (too small to warrant styling)
1223
+ if (items.length < 3) continue;
1224
+
1225
+ // Skip if list is inside code blocks or pre tags
1226
+ if (list.closest('pre') || list.closest('code')) continue;
1227
+
1228
+ // Determine if this is main content or nested content
1229
+ const isNestedInAccordion = !!list.closest('[data-component="accordion"], .accordion-content');
1230
+ const isNestedInCollapse = !!list.closest('[data-component="collapse"], .collapse-content');
1231
+ const isNestedInModal = !!list.closest('[data-component="modal"], .modal-content');
1232
+ const _isNestedInCard = !!list.closest('.card');
1233
+ const isInsideSpecialFormatting = !!list.closest('.pattern-');
1234
+
1235
+ const isNested = isNestedInAccordion || isNestedInCollapse || isNestedInModal;
1236
+
1237
+ // Check if list has styling (includes intentional unstyled patterns)
1238
+ const hasStyledClass = list.classList.contains('list-styled') ||
1239
+ list.classList.contains('list-numbered') ||
1240
+ list.classList.contains('list-disc') ||
1241
+ list.classList.contains('list-decimal') ||
1242
+ list.classList.contains('list-none');
1243
+
1244
+ // Determine if list is an ordered or unordered list
1245
+ const isOrderedList = list.tagName === 'OL';
1246
+ const expectedClass = isOrderedList ? 'list-numbered' : 'list-styled';
1247
+
1248
+ if (!hasStyledClass) {
1249
+ const listType = isOrderedList ? 'Ordered' : 'Unordered';
1250
+ const listContext = isNested ? 'nested' : 'main content';
1251
+ const listPreview = items[0]?.textContent.substring(0, 40) || 'list';
1252
+
1253
+ if (isNested) {
1254
+ // Nested lists: warning only (optional styling)
1255
+ warnings.push(`Slide "${slideId}": ${listType} list with ${items.length} items in ${listContext} lacks styling. Consider using .${expectedClass} for consistency. First item: "${listPreview}..."`);
1256
+ } else if (!isInsideSpecialFormatting) {
1257
+ // Main content lists: error-level warning (should be styled)
1258
+ warnings.push(`Slide "${slideId}": Main content ${listType.toLowerCase()} list with ${items.length} items should use .${expectedClass} for improved readability and visual hierarchy. First item: "${listPreview}..."`);
1259
+ }
1260
+ }
1261
+ }
1262
+ }
1263
+
1264
+ /**
1265
+ * Validates modal audio patterns.
1266
+ * Checks for declarative modal triggers with audio and reminds course authors about the pattern.
1267
+ * Note: Modal.js automatically renders compact audio controls in the footer when audio is present,
1268
+ * so course authors don't need to manually include them.
1269
+ *
1270
+ * @param {string} slideId - The slide identifier
1271
+ * @param {HTMLElement} renderedContent - The rendered slide DOM
1272
+ * @param {array} warnings - Array to collect warnings
1273
+ */
1274
+ function validateModalAudioPatterns(slideId, renderedContent, _warnings) {
1275
+ // Find all declarative modal triggers with audio configuration
1276
+ const modalTriggersWithAudio = renderedContent.querySelectorAll('[data-modal-trigger][data-audio-src], [data-component="modal-trigger"][data-audio-src]');
1277
+
1278
+ if (modalTriggersWithAudio.length > 0) {
1279
+ // This is just a reminder note about the pattern
1280
+ logger.debug(`[RuntimeLinter] ${slideId}: Found ${modalTriggersWithAudio.length} modal(s) with audio - framework handles compact audio player automatically in modal footer.`);
1281
+
1282
+ // Optionally add a more detailed warning if required audio is used
1283
+ for (const trigger of modalTriggersWithAudio) {
1284
+ const isAudioRequired = trigger.getAttribute('data-audio-required') === 'true';
1285
+ if (isAudioRequired) {
1286
+ const triggerText = trigger.textContent.trim().substring(0, 40);
1287
+ logger.debug(`[RuntimeLinter] ${slideId}: Modal "${triggerText}" has required audio - completion will be tracked automatically.`);
1288
+ }
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ /**
1294
+ * Validates component structure against catalog schemas.
1295
+ * Checks that required child elements exist within components.
1296
+ *
1297
+ * @param {string} slideId - The slide identifier
1298
+ * @param {HTMLElement} renderedContent - The rendered slide DOM
1299
+ * @param {array} warnings - Array to collect warnings
1300
+ */
1301
+ function validateComponentStructure(slideId, renderedContent, warnings) {
1302
+ const components = renderedContent.querySelectorAll('[data-component]');
1303
+
1304
+ // Build set of known sub-component types from schema structure references
1305
+ // e.g. modal declares trigger: '[data-component="modal-trigger"]'
1306
+ const subComponentTypes = new Set();
1307
+ for (const type of getRegisteredComponentTypes()) {
1308
+ const schema = getComponentSchema(type);
1309
+ if (!schema?.structure) continue;
1310
+ for (const val of Object.values(schema.structure)) {
1311
+ if (typeof val === 'string') {
1312
+ const match = val.match(/data-component="([^"]+)"/);
1313
+ if (match) subComponentTypes.add(match[1]);
1314
+ }
1315
+ }
1316
+ }
1317
+
1318
+ for (const component of components) {
1319
+ const type = component.dataset.component;
1320
+
1321
+ // Skip known sub-component types (e.g. modal-trigger is part of modal)
1322
+ if (subComponentTypes.has(type)) continue;
1323
+
1324
+ // Only validate components that are registered in the catalog
1325
+ if (!isComponentRegistered(type)) {
1326
+ warnings.push(`Slide "${slideId}": Unknown component type "${type}" - not found in component catalog.`);
1327
+ continue;
1328
+ }
1329
+
1330
+ const schema = getComponentSchema(type);
1331
+ if (!schema || !schema.structure?.children) {
1332
+ continue; // No structure defined, skip validation
1333
+ }
1334
+
1335
+ // Check for simplified syntax (e.g. accordion with data-title attributes)
1336
+ // Components using simplified authoring syntax have their children generated at init time
1337
+ const usesSimplifiedSyntax = component.querySelector(':scope > [data-title]');
1338
+ if (usesSimplifiedSyntax) continue;
1339
+
1340
+ // Validate required children
1341
+ for (const [childName, childDef] of Object.entries(schema.structure.children)) {
1342
+ if (!childDef.required) continue;
1343
+
1344
+ const selector = childDef.selector;
1345
+ const matches = component.querySelectorAll(selector);
1346
+
1347
+ if (matches.length === 0) {
1348
+ warnings.push(`Slide "${slideId}": Component "${type}" missing required child "${childName}" (selector: ${selector}).`);
1349
+ } else if (childDef.minItems && matches.length < childDef.minItems) {
1350
+ warnings.push(`Slide "${slideId}": Component "${type}" has ${matches.length} "${childName}" element(s) but requires at least ${childDef.minItems}.`);
1351
+ }
1352
+ }
1353
+ }
1354
+ }
1355
+
1356
+ /**
1357
+ * Builds a Set of all valid CSS class names from loaded stylesheets (CSSOM).
1358
+ * This is the runtime equivalent of lib/css-index.js — instead of parsing files
1359
+ * with PostCSS, we read the already-loaded stylesheets from the browser.
1360
+ *
1361
+ * @returns {Set<string>} Set of valid CSS class names
1362
+ */
1363
+
1364
+ /**
1365
+ * Validates element spacing — catches missing gaps, zero-margin siblings, and unpadded containers.
1366
+ * These are the most common visual layout issues in AI-authored slides.
1367
+ *
1368
+ * Checks:
1369
+ * 1. Flex/grid containers with 2+ children but no gap
1370
+ * 2. Adjacent block siblings with zero gap between them
1371
+ * 3. Visual containers (background/border) with no internal padding
1372
+ *
1373
+ * Suppressed by data-lint-ignore="spacing" on the element or any ancestor.
1374
+ *
1375
+ * @param {string} slideId - The slide identifier
1376
+ * @param {HTMLElement} renderedContent - The rendered slide DOM
1377
+ * @param {array} warnings - Array to collect warnings
1378
+ */
1379
+ function validateElementSpacing(slideId, renderedContent, warnings) {
1380
+ // Framework components that manage their own spacing (skip these)
1381
+ const FRAMEWORK_MANAGED = new Set([
1382
+ 'accordion', 'accordion-item', 'accordion-header', 'accordion-content',
1383
+ 'tab-button', 'tab-content', 'tab-list', 'content-tabs', 'assessment-tabs',
1384
+ 'card', 'card-header', 'card-body', 'card-footer',
1385
+ 'modal', 'modal-content', 'modal-header', 'modal-body', 'modal-footer',
1386
+ 'callout', 'alert', 'notification',
1387
+ 'carousel', 'carousel-item', 'carousel-controls',
1388
+ 'dropdown', 'dropdown-menu', 'dropdown-item',
1389
+ 'table', 'step', 'step-number', 'step-content',
1390
+ 'list-styled', 'list-numbered',
1391
+ 'btn', 'btn-group', 'badge',
1392
+ 'pattern-steps', 'stat-card', 'stat-value',
1393
+ 'nav-pills', 'breadcrumb'
1394
+ ]);
1395
+
1396
+ // Tags considered block-level for sibling gap checking
1397
+ const BLOCK_TAGS = new Set([
1398
+ 'DIV', 'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
1399
+ 'SECTION', 'ARTICLE', 'UL', 'OL', 'BLOCKQUOTE',
1400
+ 'FIGURE', 'TABLE', 'FORM', 'DETAILS'
1401
+ ]);
1402
+
1403
+ const isFrameworkManaged = (el) => {
1404
+ const classes = el.className?.toString().split(' ') || [];
1405
+ return classes.some(cls => FRAMEWORK_MANAGED.has(cls)) ||
1406
+ !!el.closest('[data-component]') ||
1407
+ !!el.closest('[data-interaction-id]');
1408
+ };
1409
+
1410
+ const isVisible = (el) => {
1411
+ if (el.offsetParent === null && window.getComputedStyle(el).position !== 'fixed') return false;
1412
+ const style = window.getComputedStyle(el);
1413
+ return style.display !== 'none' && style.visibility !== 'hidden';
1414
+ };
1415
+
1416
+ // --- Check 1: Flex/grid containers without gap ---
1417
+ const allElements = renderedContent.querySelectorAll('*');
1418
+ for (const el of allElements) {
1419
+ if (!isVisible(el)) continue;
1420
+ if (isLintIgnored(el, 'spacing')) continue;
1421
+ if (isFrameworkManaged(el)) continue;
1422
+
1423
+ const style = window.getComputedStyle(el);
1424
+ const display = style.display;
1425
+
1426
+ const isFlex = display === 'flex' || display === 'inline-flex';
1427
+ const isGrid = display === 'grid' || display === 'inline-grid';
1428
+
1429
+ if (!isFlex && !isGrid) continue;
1430
+
1431
+ // Count visible children
1432
+ const visibleChildren = Array.from(el.children).filter(child => isVisible(child));
1433
+ if (visibleChildren.length < 2) continue;
1434
+
1435
+ // Check gap value
1436
+ const gap = style.gap;
1437
+ const rowGap = style.rowGap;
1438
+ const columnGap = style.columnGap;
1439
+ const hasGap = (gap && gap !== 'normal' && gap !== '0px') ||
1440
+ (rowGap && rowGap !== 'normal' && rowGap !== '0px') ||
1441
+ (columnGap && columnGap !== 'normal' && columnGap !== '0px');
1442
+
1443
+ if (!hasGap) {
1444
+ // Check if children have margins that create effective spacing
1445
+ const flexDir = style.flexDirection || 'row';
1446
+ const isColumn = flexDir === 'column' || flexDir === 'column-reverse';
1447
+ let hasChildMargins = false;
1448
+
1449
+ for (let i = 0; i < visibleChildren.length - 1; i++) {
1450
+ const childStyle = window.getComputedStyle(visibleChildren[i]);
1451
+ const nextStyle = window.getComputedStyle(visibleChildren[i + 1]);
1452
+ if (isColumn) {
1453
+ if (parseFloat(childStyle.marginBottom) > 0 || parseFloat(nextStyle.marginTop) > 0) {
1454
+ hasChildMargins = true;
1455
+ break;
1456
+ }
1457
+ } else {
1458
+ if (parseFloat(childStyle.marginRight) > 0 || parseFloat(nextStyle.marginLeft) > 0) {
1459
+ hasChildMargins = true;
1460
+ break;
1461
+ }
1462
+ }
1463
+ }
1464
+
1465
+ if (!hasChildMargins) {
1466
+ const layoutType = isFlex ? 'Flex' : 'Grid';
1467
+ warnings.push(`Slide "${slideId}": ${layoutType} container with ${visibleChildren.length} children has no gap or margin spacing. Add a gap class (e.g., gap-3, gap-4).\n ${getElementClassContext(el)}`);
1468
+ }
1469
+ }
1470
+ }
1471
+
1472
+ // --- Check 2: Adjacent block siblings with zero spacing ---
1473
+ // Walk direct children of common container elements
1474
+ const containers = renderedContent.querySelectorAll('.slide, section, [class*="col-"], [class*="content"]');
1475
+ for (const container of containers) {
1476
+ if (isLintIgnored(container, 'spacing')) continue;
1477
+ if (isFrameworkManaged(container)) continue;
1478
+
1479
+ // Skip flex/grid — handled by Check 1
1480
+ const containerStyle = window.getComputedStyle(container);
1481
+ if (['flex', 'inline-flex', 'grid', 'inline-grid'].includes(containerStyle.display)) continue;
1482
+
1483
+ const children = Array.from(container.children).filter(child =>
1484
+ isVisible(child) && BLOCK_TAGS.has(child.tagName)
1485
+ );
1486
+
1487
+ for (let i = 0; i < children.length - 1; i++) {
1488
+ const current = children[i];
1489
+ const next = children[i + 1];
1490
+
1491
+ if (isLintIgnored(current, 'spacing') || isLintIgnored(next, 'spacing')) continue;
1492
+ if (isFrameworkManaged(current) || isFrameworkManaged(next)) continue;
1493
+
1494
+ const currentStyle = window.getComputedStyle(current);
1495
+ const nextStyle = window.getComputedStyle(next);
1496
+
1497
+ const marginBottom = parseFloat(currentStyle.marginBottom) || 0;
1498
+ const marginTop = parseFloat(nextStyle.marginTop) || 0;
1499
+ const effectiveGap = Math.max(marginBottom, marginTop); // Margin collapse
1500
+
1501
+ if (effectiveGap < 1) {
1502
+ const currentDesc = current.tagName.toLowerCase() + (current.className ? `.${current.className.split(' ')[0]}` : '');
1503
+ const nextDesc = next.tagName.toLowerCase() + (next.className ? `.${next.className.split(' ')[0]}` : '');
1504
+ warnings.push(`Slide "${slideId}": No spacing between adjacent <${currentDesc}> and <${nextDesc}>. Add margin (e.g., mb-3, mb-4) or wrap in a flex container with gap.\n ${getElementClassContext(current)}`);
1505
+ }
1506
+ }
1507
+ }
1508
+
1509
+ // --- Check 3: Visual containers with no padding ---
1510
+ for (const el of allElements) {
1511
+ if (!isVisible(el)) continue;
1512
+ if (isLintIgnored(el, 'spacing')) continue;
1513
+ if (isFrameworkManaged(el)) continue;
1514
+
1515
+ // Must have children with content
1516
+ if (el.children.length === 0) continue;
1517
+ if (!el.textContent || el.textContent.trim() === '') continue;
1518
+
1519
+ const style = window.getComputedStyle(el);
1520
+
1521
+ // Check for visual boundary (background, border, or shadow)
1522
+ const bgColor = style.backgroundColor;
1523
+ const hasBg = bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent';
1524
+ const hasBorder = parseFloat(style.borderTopWidth) > 0 ||
1525
+ parseFloat(style.borderBottomWidth) > 0 ||
1526
+ parseFloat(style.borderLeftWidth) > 0 ||
1527
+ parseFloat(style.borderRightWidth) > 0;
1528
+ const hasShadow = style.boxShadow && style.boxShadow !== 'none';
1529
+ const hasBorderRadius = parseFloat(style.borderRadius) > 0;
1530
+
1531
+ if (!hasBg && !hasBorder && !hasShadow && !hasBorderRadius) continue;
1532
+
1533
+ // Check if ALL padding values are 0
1534
+ const paddingTop = parseFloat(style.paddingTop) || 0;
1535
+ const paddingBottom = parseFloat(style.paddingBottom) || 0;
1536
+ const paddingLeft = parseFloat(style.paddingLeft) || 0;
1537
+ const paddingRight = parseFloat(style.paddingRight) || 0;
1538
+
1539
+ if (paddingTop === 0 && paddingBottom === 0 && paddingLeft === 0 && paddingRight === 0) {
1540
+ // Only warn for elements that look like content containers (not tiny decorative elements)
1541
+ const rect = el.getBoundingClientRect();
1542
+ if (rect.width < 50 || rect.height < 30) continue;
1543
+
1544
+ const boundary = hasBg ? 'background' : hasBorder ? 'border' : hasShadow ? 'box-shadow' : 'border-radius';
1545
+ warnings.push(`Slide "${slideId}": Container with ${boundary} has no internal padding — content touches edges. Add padding (e.g., p-3, p-4).\n ${getElementClassContext(el)}`);
1546
+ }
1547
+ }
1548
+ }
1549
+
1550
+ /**
1551
+ * Validates content overflow — detects elements whose content exceeds their container bounds.
1552
+ * Common AI mistake: generating too much text or too many elements for the available space.
1553
+ *
1554
+ * Checks scrollHeight > clientHeight (vertical) and scrollWidth > clientWidth (horizontal).
1555
+ *
1556
+ * Suppressed by data-lint-ignore="overflow" on the element or any ancestor.
1557
+ *
1558
+ * @param {string} slideId - The slide identifier
1559
+ * @param {HTMLElement} renderedContent - The rendered slide DOM
1560
+ * @param {array} warnings - Array to collect warnings
1561
+ */
1562
+ function validateContentOverflow(slideId, renderedContent, warnings) {
1563
+ const OVERFLOW_THRESHOLD = 20; // px — ignore trivial sub-pixel rounding
1564
+
1565
+ // --- Presentation layout: content is clipped, not scrollable ---
1566
+ const layout = document.documentElement.getAttribute('data-layout');
1567
+ if (layout === 'presentation') {
1568
+ if (isLintIgnored(renderedContent, 'overflow')) return;
1569
+
1570
+ const slideContainer = document.getElementById('slide-container') || renderedContent;
1571
+ const contentHeight = slideContainer.scrollHeight;
1572
+ const viewportHeight = window.innerHeight;
1573
+
1574
+ if (contentHeight > viewportHeight + OVERFLOW_THRESHOLD) {
1575
+ const pct = Math.round((contentHeight / viewportHeight) * 100);
1576
+ warnings.push(
1577
+ `Slide "${slideId}": Content height (${contentHeight}px) exceeds viewport (${viewportHeight}px) in presentation layout — ` +
1578
+ `${pct}% of viewport, content will be clipped. Reduce content or switch to a scrollable layout.\n ` +
1579
+ 'Suppress with data-lint-ignore="overflow" on the slide element.'
1580
+ );
1581
+ }
1582
+ return; // Presentation layout doesn't need individual container checks
1583
+ }
1584
+
1585
+ // Framework containers that intentionally scroll
1586
+ const SCROLLABLE_INTENTS = new Set([
1587
+ 'accordion-content', 'modal-body', 'modal-content',
1588
+ 'tab-content', 'carousel', 'overflow-auto', 'overflow-y-auto', 'overflow-x-auto',
1589
+ 'code', 'pre'
1590
+ ]);
1591
+
1592
+ const isIntentionallyScrollable = (el) => {
1593
+ const classes = el.className?.toString().split(' ') || [];
1594
+ if (classes.some(cls => SCROLLABLE_INTENTS.has(cls))) return true;
1595
+ const style = window.getComputedStyle(el);
1596
+ // Author explicitly set overflow to scroll/auto = intentional
1597
+ return style.overflowY === 'scroll' || style.overflowX === 'scroll';
1598
+ };
1599
+
1600
+ // Check the slide section itself and major content containers
1601
+ const containers = renderedContent.querySelectorAll('section.slide, [class*="content"], [class*="col-"], [data-layout-body], .card-body');
1602
+ for (const el of containers) {
1603
+ if (el.offsetParent === null) continue;
1604
+ if (isLintIgnored(el, 'overflow')) continue;
1605
+ if (isIntentionallyScrollable(el)) continue;
1606
+ if (el.closest('[data-component]') || el.closest('[data-interaction-id]')) continue;
1607
+
1608
+ const vertOverflow = el.scrollHeight - el.clientHeight;
1609
+ const horizOverflow = el.scrollWidth - el.clientWidth;
1610
+
1611
+ if (vertOverflow > OVERFLOW_THRESHOLD) {
1612
+ const pct = Math.round((el.scrollHeight / el.clientHeight) * 100);
1613
+ warnings.push(`Slide "${slideId}": Content overflows container vertically (${pct}% of visible area). Reduce content or use a scrollable component.\n ${getElementClassContext(el)}`);
1614
+ }
1615
+
1616
+ if (horizOverflow > OVERFLOW_THRESHOLD) {
1617
+ warnings.push(`Slide "${slideId}": Content overflows container horizontally by ${horizOverflow}px. Check for fixed-width elements or long unbroken text.\n ${getElementClassContext(el)}`);
1618
+ }
1619
+ }
1620
+ }
1621
+
1622
+ function buildCssClassIndex() {
1623
+ const classes = new Set();
1624
+
1625
+ try {
1626
+ for (const sheet of document.styleSheets) {
1627
+ try {
1628
+ const rules = sheet.cssRules || sheet.rules;
1629
+ if (!rules) continue;
1630
+ extractClassesFromRules(rules, classes);
1631
+ } catch {
1632
+ // Cross-origin stylesheets throw SecurityError — skip them
1633
+ }
1634
+ }
1635
+ } catch {
1636
+ // If styleSheets is inaccessible, return empty set (validation will be skipped)
1637
+ }
1638
+
1639
+ logger.debug(`[RuntimeLinter] CSS class index: ${classes.size} classes from ${document.styleSheets.length} stylesheets`);
1640
+ return classes;
1641
+ }
1642
+
1643
+ /**
1644
+ * Recursively extracts class names from CSS rules (handles @media, @supports, etc.).
1645
+ * @param {CSSRuleList} rules - The CSS rules to extract from
1646
+ * @param {Set<string>} classes - Set to accumulate class names into
1647
+ */
1648
+ function extractClassesFromRules(rules, classes) {
1649
+ for (const rule of rules) {
1650
+ if (rule.selectorText) {
1651
+ const classMatches = rule.selectorText.match(/\.[\w-]+/g);
1652
+ if (classMatches) {
1653
+ for (const match of classMatches) {
1654
+ classes.add(match.slice(1)); // strip leading dot
1655
+ }
1656
+ }
1657
+ }
1658
+ // Recurse into grouped rules (@media, @supports, @layer, etc.)
1659
+ if (rule.cssRules) {
1660
+ extractClassesFromRules(rule.cssRules, classes);
1661
+ }
1662
+ }
1663
+ }
1664
+
1665
+ /**
1666
+ * Validates CSS classes on rendered DOM elements against the CSSOM class index.
1667
+ * Flags classes that don't exist in any loaded stylesheet, which usually indicates
1668
+ * a hallucinated or wrong-framework class name (e.g., Bootstrap's "d-flex" instead
1669
+ * of this framework's "flex").
1670
+ *
1671
+ * @param {string} slideId - The slide identifier for error messages
1672
+ * @param {HTMLElement} renderedContent - The rendered slide DOM
1673
+ * @param {Set<string>} validCssClasses - Set of valid CSS class names from CSSOM
1674
+ * @param {array} warnings - Array to collect warnings
1675
+ */
1676
+ function validateCssClasses(slideId, renderedContent, validCssClasses, warnings) {
1677
+ // If the index is empty, skip validation (stylesheets may not be accessible)
1678
+ if (validCssClasses.size === 0) return;
1679
+
1680
+ const undefinedClasses = new Map(); // className -> count
1681
+
1682
+ const allElements = renderedContent.querySelectorAll('*');
1683
+ for (const el of allElements) {
1684
+ for (const cls of el.classList) {
1685
+ if (validCssClasses.has(cls)) continue;
1686
+ if (DYNAMIC_CLASSES.has(cls)) continue;
1687
+ if (DYNAMIC_CLASS_PREFIXES.some(p => cls.startsWith(p))) continue;
1688
+ undefinedClasses.set(cls, (undefinedClasses.get(cls) || 0) + 1);
1689
+ }
1690
+ }
1691
+
1692
+ for (const [cls, count] of undefinedClasses) {
1693
+ const suffix = count > 1 ? ` (used ${count} times)` : '';
1694
+ warnings.push(
1695
+ `Slide "${slideId}": CSS class "${cls}" is not defined in any stylesheet${suffix}. ` +
1696
+ 'This may be a hallucinated or wrong-framework class name.'
1697
+ );
1698
+ }
1699
+ }
1700
+
1701
+ /** Color variant classes that satisfy the btn variant requirement */
1702
+ const BTN_COLOR_VARIANTS = new Set([
1703
+ 'btn-primary', 'btn-secondary', 'btn-success', 'btn-info',
1704
+ 'btn-warning', 'btn-danger', 'btn-reset', 'btn-gradient', 'btn-hint',
1705
+ 'btn-outline-primary', 'btn-outline-secondary',
1706
+ ]);
1707
+
1708
+ /**
1709
+ * Validates that .btn elements always have a color variant class.
1710
+ * Suppressed by data-lint-ignore="btn-variant".
1711
+ */
1712
+ function validateButtonVariants(slideId, renderedContent, warnings) {
1713
+ const buttons = renderedContent.querySelectorAll('.btn');
1714
+ for (const btn of buttons) {
1715
+ if (isLintIgnored(btn, 'btn-variant')) continue;
1716
+
1717
+ const hasColorVariant = [...btn.classList].some(c => BTN_COLOR_VARIANTS.has(c));
1718
+ if (!hasColorVariant) {
1719
+ warnings.push(
1720
+ `Slide "${slideId}": Button has "btn" class without a color variant. ` +
1721
+ 'Add a variant like btn-primary, btn-secondary, btn-success, etc.'
1722
+ );
1723
+ }
1724
+ }
1725
+ }