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,386 @@
1
+ /**
2
+ * @file modal.js
3
+ * @description Dynamic modal management system with audio support.
4
+ *
5
+ * Usage:
6
+ * import * as Modal from './modal.js';
7
+ *
8
+ * // In main application entry point:
9
+ * Modal.setup();
10
+ *
11
+ * // To show a modal:
12
+ * Modal.show({
13
+ * title: 'My Title',
14
+ * body: '<p>My content.</p>',
15
+ * footer: '<button data-action="close-modal">Close</button>',
16
+ * config: { closeOnBackdrop: true, closeOnEscape: true },
17
+ * audio: {
18
+ * src: 'audio/modal-narration.mp3',
19
+ * autoplay: true,
20
+ * required: true, // Audio must complete before modal counts as viewed
21
+ * completionThreshold: 0.9 // 90% listened = complete
22
+ * },
23
+ * onOpen: () => console.log('Modal opened!'),
24
+ * onClose: () => console.log('Modal closed!'),
25
+ * });
26
+ *
27
+ * Declarative Audio:
28
+ * <button data-modal-trigger="my-modal"
29
+ * data-audio-src="audio/modal.mp3"
30
+ * data-audio-required="true"
31
+ * data-audio-threshold="0.9">
32
+ */
33
+
34
+ import { iconManager } from '../../utilities/icons.js';
35
+ import { announceToScreenReader } from './index.js';
36
+ import { trapFocus } from '../../utilities/utilities.js';
37
+ import audioManager from '../../managers/audio-manager.js';
38
+ import engagementManager from '../../engagement/engagement-manager.js';
39
+ import * as NavigationState from '../../navigation/NavigationState.js';
40
+ import * as AudioPlayer from './audio-player.js';
41
+ import { eventBus } from '../../core/event-bus.js';
42
+ import { logger } from '../../utilities/logger.js';
43
+ import { renderCompactPlayer } from './audio-player.js';
44
+
45
+ // Schema for validation, linting, and AI-assisted authoring
46
+ export const schema = {
47
+ type: 'modal-trigger',
48
+ description: 'Dynamic modal with audio support and focus trapping',
49
+ example: '<button data-component=\'modal-trigger\' data-title=\'Welcome\' data-body=\'<p>This modal supports rich content, audio narration, and focus trapping for accessibility.</p>\' class=\'btn btn-primary\'>Open Modal</button>',
50
+ properties: {
51
+ closeOnBackdrop: { type: 'boolean', default: true, description: 'Close when clicking backdrop' },
52
+ closeOnEscape: { type: 'boolean', default: true, description: 'Close on Escape key' },
53
+ hideCloseButton: { type: 'boolean', default: false, description: 'Hide the X close button' }
54
+ },
55
+ structure: {
56
+ trigger: '[data-modal-trigger], [data-component="modal-trigger"]',
57
+ modal: '#global-modal',
58
+ backdrop: '.modal-backdrop'
59
+ }
60
+ };
61
+
62
+ export const metadata = {
63
+ category: 'ui-component',
64
+ cssFile: 'components/modals.css',
65
+ engagementTracking: 'viewAllModals',
66
+ emitsEvents: ['modal:opened', 'modal:closed']
67
+ };
68
+
69
+ let modalElement = null;
70
+ let modalTitle = null;
71
+ let modalBody = null;
72
+ let modalFooter = null;
73
+ let backdropElement = null;
74
+
75
+ let activeConfig = {};
76
+ let previousFocus = null;
77
+ let isInitialized = false;
78
+ let currentModalId = null; // Track current modal for audio completion
79
+ let audioCompletedHandler = null; // Audio completion event handler
80
+ let currentSlideId = null; // Track slide ID for engagement tracking
81
+
82
+ /**
83
+ * Checks if the modal is currently visible.
84
+ * @returns {boolean}
85
+ */
86
+ function isVisible() {
87
+ return modalElement && modalElement.classList.contains('active');
88
+ }
89
+
90
+ /**
91
+ * Initializes the modal system. Must be called once on app startup.
92
+ */
93
+ export function setup() {
94
+ if (isInitialized) {
95
+ return;
96
+ }
97
+
98
+ modalElement = document.getElementById('global-modal');
99
+ backdropElement = document.querySelector('.modal-backdrop');
100
+
101
+ // Inject standard close icon
102
+ if (modalElement) {
103
+ const closeBtn = modalElement.querySelector('.modal-close');
104
+ if (closeBtn) {
105
+ closeBtn.innerHTML = iconManager.getIcon('x');
106
+ }
107
+ }
108
+
109
+ if (!modalElement || !backdropElement) {
110
+ logger.fatal('Modal elements (#global-modal, .modal-backdrop) not found in the DOM.', { domain: 'ui', operation: 'Modal.setup' });
111
+ return;
112
+ }
113
+
114
+ modalTitle = document.getElementById('global-modal-title');
115
+ modalBody = document.getElementById('global-modal-body');
116
+ modalFooter = document.getElementById('global-modal-footer');
117
+
118
+ // Close button handler (delegated to handle dynamic content)
119
+ modalElement.addEventListener('click', (event) => {
120
+ if (event.target.closest('[data-action="close-modal"]')) {
121
+ hide();
122
+ }
123
+ });
124
+
125
+ // Backdrop click handler
126
+ backdropElement.addEventListener('click', () => {
127
+ if (activeConfig.closeOnBackdrop) {
128
+ hide();
129
+ }
130
+ });
131
+
132
+ // Global Escape key handler
133
+ document.addEventListener('keydown', (e) => {
134
+ if (e.key === 'Escape' && isVisible() && activeConfig.closeOnEscape) {
135
+ hide();
136
+ }
137
+ });
138
+
139
+ isInitialized = true;
140
+ }
141
+
142
+ /**
143
+ * Initializes a single declarative modal trigger.
144
+ * @param {HTMLElement} trigger
145
+ */
146
+ export function init(trigger) {
147
+ trigger.addEventListener('click', () => {
148
+ const title = trigger.dataset.title || 'Modal';
149
+ let body = trigger.dataset.body || '';
150
+ let footer = trigger.dataset.footer || '<button class="btn btn-secondary" data-action="close-modal">Close</button>';
151
+
152
+ // If body/footer starts with #, try to find element and use its HTML
153
+ // Handle both regular elements and <template> elements
154
+ if (body.startsWith('#')) {
155
+ const el = document.querySelector(body);
156
+ if (el) {
157
+ if (el.tagName === 'TEMPLATE') {
158
+ // For <template> elements, clone content and extract HTML
159
+ const tempDiv = document.createElement('div');
160
+ tempDiv.appendChild(el.content.cloneNode(true));
161
+ body = tempDiv.innerHTML;
162
+ } else {
163
+ body = el.innerHTML;
164
+ }
165
+ }
166
+ }
167
+ if (footer.startsWith('#')) {
168
+ const el = document.querySelector(footer);
169
+ if (el) {
170
+ if (el.tagName === 'TEMPLATE') {
171
+ const tempDiv = document.createElement('div');
172
+ tempDiv.appendChild(el.content.cloneNode(true));
173
+ footer = tempDiv.innerHTML;
174
+ } else {
175
+ footer = el.innerHTML;
176
+ }
177
+ }
178
+ }
179
+
180
+ // Check for audio configuration on trigger
181
+ const audioSrc = trigger.dataset.audioSrc;
182
+ const audioConfig = audioSrc ? {
183
+ src: audioSrc,
184
+ autoplay: trigger.dataset.audioAutoplay === 'true',
185
+ required: trigger.dataset.audioRequired === 'true',
186
+ completionThreshold: parseFloat(trigger.dataset.audioThreshold) || 0.95
187
+ } : null;
188
+
189
+ // Get modal ID for tracking (from trigger id or generate one)
190
+ const modalId = trigger.dataset.modalId || trigger.id || `modal-${Date.now()}`;
191
+
192
+ show({
193
+ title,
194
+ body,
195
+ footer,
196
+ audio: audioConfig,
197
+ modalId,
198
+ config: {
199
+ closeOnBackdrop: trigger.dataset.closeOnBackdrop !== 'false',
200
+ closeOnEscape: trigger.dataset.closeOnEscape !== 'false'
201
+ }
202
+ });
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Shows the global modal with dynamic content.
208
+ * @param {object} options - The modal configuration.
209
+ * @param {string} options.title - The text for the modal title.
210
+ * @param {string} options.body - The HTML string for the modal body.
211
+ * @param {string} [options.footer] - The HTML string for the modal footer.
212
+ * @param {string} [options.modalId] - Unique identifier for this modal (for engagement tracking).
213
+ * @param {object} [options.config] - Behavior configuration.
214
+ * @param {boolean} [options.config.closeOnBackdrop=true] - If true, clicking the backdrop closes the modal.
215
+ * @param {boolean} [options.config.closeOnEscape=true] - If true, pressing Escape closes the modal.
216
+ * @param {boolean} [options.config.hideCloseButton=false] - If true, hides the modal's close (X) button.
217
+ * @param {object} [options.audio] - Audio configuration for modal narration.
218
+ * @param {string} options.audio.src - Audio file source path.
219
+ * @param {boolean} [options.audio.autoplay=true] - Whether to autoplay the audio.
220
+ * @param {boolean} [options.audio.required=false] - Whether audio must complete for modal engagement.
221
+ * @param {number} [options.audio.completionThreshold=0.95] - Percentage (0-1) for completion.
222
+ * @param {Function} [options.onOpen] - Callback executed when the modal opens.
223
+ * @param {Function} [options.onClose] - Callback executed when the modal closes.
224
+ */
225
+ export async function show({ title, body, footer = '', modalId = null, config = {}, audio = null, onOpen, onClose }) {
226
+ if (!isInitialized) {
227
+ throw new Error('Modal system not initialized. Call setup() first.');
228
+ }
229
+
230
+ // Generate modal ID if not provided
231
+ currentModalId = modalId || `modal-${Date.now()}`;
232
+
233
+ // Store config for this active session
234
+ activeConfig = {
235
+ closeOnBackdrop: config.closeOnBackdrop !== false,
236
+ closeOnEscape: config.closeOnEscape !== false,
237
+ hideCloseButton: config.hideCloseButton === true,
238
+ audio,
239
+ modalId: currentModalId,
240
+ onOpen,
241
+ onClose,
242
+ };
243
+
244
+ // Show/hide the close button based on config
245
+ const closeBtn = modalElement.querySelector('.modal-close');
246
+ if (closeBtn) {
247
+ closeBtn.style.display = activeConfig.hideCloseButton ? 'none' : '';
248
+ }
249
+
250
+ // Populate content
251
+ modalTitle.textContent = title;
252
+ modalBody.innerHTML = body;
253
+
254
+ // If audio is present, prepend compact audio player to footer
255
+ if (audio && audio.src) {
256
+ const compactAudioHtml = renderCompactPlayer();
257
+ modalFooter.innerHTML = compactAudioHtml + footer;
258
+ } else {
259
+ modalFooter.innerHTML = footer;
260
+ }
261
+
262
+ // Save current focus
263
+ previousFocus = document.activeElement;
264
+
265
+ // Save the current slide ID (for engagement tracking)
266
+ currentSlideId = NavigationState.getCurrentSlideId();
267
+
268
+ // Track modal view for engagement (if this modal is registered for tracking)
269
+ if (currentSlideId && currentModalId) {
270
+ engagementManager.trackModalView(currentSlideId, currentModalId);
271
+ }
272
+
273
+ // Handle audio: load modal audio
274
+ // Note: Due to singleton audio element, slide audio and modal audio are mutually exclusive
275
+ // (enforced by runtime-linter - a slide cannot have both)
276
+ if (audioManager.isReady() && audio && audio.src) {
277
+ const audioContextId = `modal-${currentModalId}`;
278
+ try {
279
+ await audioManager.load({
280
+ src: audio.src,
281
+ autoplay: audio.autoplay === true,
282
+ required: audio.required || false,
283
+ completionThreshold: audio.completionThreshold || 0.95
284
+ }, audioContextId, 'modal');
285
+
286
+ logger.debug(`[Modal] Loaded modal audio: ${audioContextId}`);
287
+
288
+ // Initialize event listeners AFTER audio is loaded
289
+ if (modalFooter) {
290
+ AudioPlayer.initAudioControlsInContainer(modalFooter);
291
+ }
292
+ } catch (err) {
293
+ logger.warn('[Modal] Failed to load modal audio:', err.message);
294
+ }
295
+
296
+ // If audio is required, listen for completion
297
+ if (audio.required) {
298
+ audioCompletedHandler = ({ contextId }) => {
299
+ if (contextId === audioContextId && currentSlideId) {
300
+ engagementManager.trackModalAudioComplete(currentSlideId, currentModalId);
301
+ }
302
+ };
303
+ eventBus.on('audio:completed', audioCompletedHandler);
304
+ }
305
+ }
306
+
307
+ // Show modal and backdrop
308
+ backdropElement.classList.add('active');
309
+ modalElement.classList.add('active');
310
+ modalElement.setAttribute('aria-hidden', 'false');
311
+
312
+ // Focus first focusable element
313
+ setTimeout(() => {
314
+ const focusable = modalElement.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
315
+ if (focusable.length > 0) {
316
+ focusable[0].focus();
317
+ }
318
+ }, 100);
319
+
320
+ // Setup focus trap
321
+ modalElement._focusTrapCleanup = trapFocus(modalElement);
322
+
323
+ if (activeConfig.onOpen) {
324
+ activeConfig.onOpen(modalElement);
325
+ }
326
+
327
+ announceToScreenReader(`Modal opened: ${title}`);
328
+ }
329
+
330
+ /**
331
+ * Hides the global modal.
332
+ */
333
+ export function hide() {
334
+ if (!isInitialized || !modalElement.classList.contains('active')) {
335
+ return;
336
+ }
337
+
338
+ modalElement.classList.remove('active');
339
+ modalElement.setAttribute('aria-hidden', 'true');
340
+ backdropElement.classList.remove('active');
341
+
342
+ // Clean up focus trap
343
+ if (modalElement._focusTrapCleanup && typeof modalElement._focusTrapCleanup === 'function') {
344
+ modalElement._focusTrapCleanup();
345
+ modalElement._focusTrapCleanup = null;
346
+ }
347
+
348
+ // Clean up audio completion listener
349
+ if (audioCompletedHandler) {
350
+ eventBus.off('audio:completed', audioCompletedHandler);
351
+ audioCompletedHandler = null;
352
+ }
353
+
354
+ // Clean up audio state update listeners from modal footer
355
+ if (modalFooter._audioStateUpdateCleanup && typeof modalFooter._audioStateUpdateCleanup === 'function') {
356
+ modalFooter._audioStateUpdateCleanup();
357
+ modalFooter._audioStateUpdateCleanup = null;
358
+ }
359
+
360
+ // Handle audio: unload modal audio
361
+ // Note: No slide audio to restore (they're mutually exclusive, enforced by runtime-linter)
362
+ if (audioManager.isReady() && activeConfig.audio) {
363
+ audioManager.unload();
364
+ logger.debug('[Modal] Unloaded modal audio');
365
+ }
366
+
367
+ // Restore focus
368
+ if (previousFocus) {
369
+ previousFocus.focus();
370
+ previousFocus = null;
371
+ }
372
+
373
+ // Fire onClose callback
374
+ if (activeConfig.onClose) {
375
+ activeConfig.onClose(modalElement);
376
+ }
377
+
378
+ // Clear content and config for next use
379
+ modalTitle.textContent = '';
380
+ modalBody.innerHTML = '';
381
+ modalFooter.innerHTML = '';
382
+ currentModalId = null;
383
+ activeConfig = {};
384
+
385
+ announceToScreenReader('Modal closed');
386
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * @file notifications.js
3
+ * @description Notification system with event delegation.
4
+ */
5
+
6
+ export const schema = {
7
+ type: 'notification-trigger',
8
+ description: 'Declarative notification trigger',
9
+ example: `<button data-action="show-notification" data-type="success" data-message="Your progress has been saved!" class="btn btn-primary" style="margin-right: 8px;">Success</button>
10
+ <button data-action="show-notification" data-type="warning" data-message="You have unsaved changes." class="btn btn-secondary" style="margin-right: 8px;">Warning</button>
11
+ <button data-action="show-notification" data-type="error" data-message="Connection lost. Please try again." class="btn btn-secondary">Error</button>`,
12
+ properties: {
13
+ type: { type: 'string', enum: ['info', 'success', 'warning', 'error'], default: 'info', dataAttribute: 'data-type' },
14
+ message: { type: 'string', required: true, dataAttribute: 'data-message' }
15
+ },
16
+ structure: {
17
+ container: '[data-action="show-notification"]',
18
+ children: {}
19
+ }
20
+ };
21
+
22
+ export const metadata = {
23
+ category: 'ui-component',
24
+ cssFile: 'components/notifications.css',
25
+ engagementTracking: null,
26
+ emitsEvents: []
27
+ };
28
+
29
+ import { announceToScreenReader } from './index.js';
30
+ import { logger } from '../../utilities/logger.js';
31
+
32
+ let notificationContainer = null;
33
+ let notificationId = 0;
34
+ let initialized = false;
35
+
36
+ export function setup() {
37
+ if (initialized) return;
38
+
39
+ notificationContainer = document.getElementById('notification-container');
40
+ if (!notificationContainer) {
41
+ logger.fatal('Notification container with ID "notification-container" not found in DOM.', { domain: 'ui', operation: 'Notifications.setup' });
42
+ return;
43
+ }
44
+
45
+ // Add a single, permanent delegated click listener
46
+ notificationContainer.addEventListener('click', (event) => {
47
+ const closeButton = event.target.closest('[data-action="dismiss-notification"]');
48
+ if (closeButton) {
49
+ const notification = closeButton.closest('.notification');
50
+ if (notification) {
51
+ dismissNotification(notification.id);
52
+ }
53
+ }
54
+ });
55
+
56
+ initialized = true;
57
+ }
58
+
59
+ /**
60
+ * Initializes declarative notification triggers in a container using event delegation.
61
+ * @param {HTMLElement} container
62
+ */
63
+ export function init(container) {
64
+ // Use event delegation to handle dynamically rendered content
65
+ container.addEventListener('click', (event) => {
66
+ const trigger = event.target.closest('[data-action="show-notification"]');
67
+ if (trigger) {
68
+ const type = trigger.dataset.type || 'info';
69
+ const message = trigger.dataset.message || 'Notification';
70
+ showNotification(message, type);
71
+ }
72
+ });
73
+ }
74
+
75
+ export function showNotification(message, type = 'info', duration = 5000, options = {}) {
76
+ if (!initialized) {
77
+ setup();
78
+ }
79
+
80
+ if (!message || typeof message !== 'string') {
81
+ throw new Error('Notification message must be a non-empty string');
82
+ }
83
+
84
+ const id = `notification-${++notificationId}`;
85
+ const notification = document.createElement('div');
86
+
87
+ notification.id = id;
88
+ notification.className = `notification notification-${type}`;
89
+ notification.setAttribute('role', type === 'error' ? 'alert' : 'status');
90
+ notification.setAttribute('data-testid', `notification-${type}`);
91
+
92
+ const dismissible = options.dismissible !== false;
93
+ const closeButton = dismissible ? `
94
+ <button class="notification-close" data-action="dismiss-notification" aria-label="Close notification" data-testid="notification-close">×</button>
95
+ ` : '';
96
+
97
+ notification.innerHTML = `
98
+ <span class="notification-message">${message}</span>
99
+ ${closeButton}
100
+ `;
101
+
102
+ notificationContainer.appendChild(notification);
103
+
104
+ if (duration > 0) {
105
+ setTimeout(() => dismissNotification(id), duration);
106
+ }
107
+
108
+ announceToScreenReader(message, type === 'error' ? 'assertive' : 'polite');
109
+
110
+ return id;
111
+ }
112
+
113
+ export function dismissNotification(id) {
114
+ const notification = document.getElementById(id);
115
+ if (!notification) return;
116
+
117
+ notification.style.animation = 'notificationSlideOut 0.3s ease-out forwards';
118
+ setTimeout(() => {
119
+ if (notification.parentNode) {
120
+ notification.parentNode.removeChild(notification);
121
+ }
122
+ }, 300);
123
+ }
124
+
125
+ export function clearAllNotifications() {
126
+ if (!notificationContainer) return;
127
+ const notifications = notificationContainer.querySelectorAll('.notification');
128
+ notifications.forEach(notification => dismissNotification(notification.id));
129
+ }
130
+
131
+ export function showSuccess(message, duration = 4000, options = {}) {
132
+ return showNotification(message, 'success', duration, options);
133
+ }
134
+
135
+ export function showWarning(message, duration = 6000, options = {}) {
136
+ return showNotification(message, 'warning', duration, options);
137
+ }
138
+
139
+ export function showError(message, duration = 8000, options = {}) {
140
+ return showNotification(message, 'error', duration, options);
141
+ }
142
+
143
+ export function showInfo(message, duration = 5000, options = {}) {
144
+ return showNotification(message, 'info', duration, options);
145
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @file progress.js
3
+ * @description Handles progress bar components.
4
+ *
5
+ * Usage (Declarative):
6
+ * <div class="progress-bar" data-component="progress" id="my-progress" data-initial-value="25">
7
+ * <div class="progress-bar-fill"></div>
8
+ * <span class="progress-bar-text">25%</span>
9
+ * </div>
10
+ *
11
+ * Usage (Imperative):
12
+ * import { updateProgress } from '...';
13
+ * updateProgress('my-progress', 50);
14
+ */
15
+
16
+ export const schema = {
17
+ type: 'progress',
18
+ description: 'Animated progress bar with percentage display',
19
+ example: `<div class="progress-bar" data-component="progress" id="preview-progress" data-initial-value="65">
20
+ <div class="progress-bar-fill" style="width: 65%"></div>
21
+ <span class="progress-bar-text">65%</span>
22
+ </div>`,
23
+ properties: {
24
+ initialValue: { type: 'number', default: 0, dataAttribute: 'data-initial-value' }
25
+ },
26
+ structure: {
27
+ container: '[data-component="progress"]',
28
+ children: {
29
+ fill: { selector: '.progress-bar-fill', required: true },
30
+ text: { selector: '.progress-bar-text', required: false }
31
+ }
32
+ }
33
+ };
34
+
35
+ export const metadata = {
36
+ category: 'ui-component',
37
+ cssFile: 'components/engagement.css',
38
+ engagementTracking: null,
39
+ emitsEvents: []
40
+ };
41
+
42
+ /**
43
+ * Sets the value of a progress bar.
44
+ * @param {HTMLElement|string} elementOrId - The progress bar container element or its ID.
45
+ * @param {number} value - The progress value (0-100).
46
+ */
47
+ import { logger } from '../../utilities/logger.js';
48
+
49
+ export function updateProgress(elementOrId, value) {
50
+ const element = typeof elementOrId === 'string' ? document.getElementById(elementOrId) : elementOrId;
51
+ if (!element) {
52
+ logger.error('updateProgress: Element not found.', elementOrId);
53
+ return;
54
+ }
55
+
56
+ const fill = element.querySelector('.progress-bar-fill');
57
+ const text = element.querySelector('.progress-bar-text');
58
+ const clampedValue = Math.max(0, Math.min(100, value));
59
+
60
+ element.style.setProperty('--progress-percent', `${clampedValue}%`);
61
+ element.setAttribute('aria-valuenow', clampedValue);
62
+
63
+ if (fill) {
64
+ fill.style.width = `${clampedValue}%`;
65
+ }
66
+ if (text) {
67
+ text.textContent = `${Math.round(clampedValue)}%`;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Initializes a progress bar component, setting its initial value.
73
+ * @param {HTMLElement} element - The progress bar container element.
74
+ */
75
+ export function init(element) {
76
+ if (!element) {
77
+ logger.fatal('initProgress: element not found.', { domain: 'ui', operation: 'initProgress' });
78
+ return;
79
+ }
80
+
81
+ const initialValue = parseFloat(element.dataset.initialValue) || 0;
82
+ updateProgress(element, initialValue);
83
+
84
+ return {
85
+ update: (value) => updateProgress(element, value),
86
+ destroy: () => {} // No listeners to remove
87
+ };
88
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Quote/Testimonial Layout Pattern
3
+ *
4
+ * CSS-only component for customer quotes, testimonials, citations.
5
+ */
6
+
7
+ export const schema = {
8
+ type: 'quote',
9
+ description: 'Quote/testimonial display with attribution',
10
+ example: `<div data-component="quote">
11
+ <p class="quote-text">"This framework has transformed how we create training content. It's intuitive, powerful, and makes our courses look professional."</p>
12
+ <div class="quote-attribution"><span class="quote-author">Jane Smith</span><span class="quote-role">Director of Training</span></div>
13
+ </div>`,
14
+ properties: {
15
+ variant: {
16
+ type: 'string',
17
+ enum: ['default', 'card', 'accent', 'featured', 'dark'],
18
+ default: 'default',
19
+ description: 'Visual style variant (add as class, e.g., quote-card)'
20
+ }
21
+ },
22
+ structure: {
23
+ container: '[data-component="quote"]',
24
+ children: {
25
+ text: { selector: '.quote-text', required: true },
26
+ attribution: { selector: '.quote-attribution' },
27
+ avatar: { selector: '.quote-avatar' },
28
+ author: { selector: '.quote-author' },
29
+ role: { selector: '.quote-role' }
30
+ }
31
+ }
32
+ };
33
+
34
+ export const metadata = {
35
+ category: 'ui-component',
36
+ cssOnly: true,
37
+ cssFile: 'components/quote.css'
38
+ };
39
+
40
+ /** No-op initializer — CSS-only component, registered for consistency. */
41
+ export function init() {}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Stats/Metrics Layout Pattern
3
+ *
4
+ * CSS-only component for displaying important numbers/statistics.
5
+ */
6
+
7
+ export const schema = {
8
+ type: 'stats',
9
+ description: 'Statistics/metrics display with large numbers',
10
+ example: `<div data-component="stats">
11
+ <div class="stat"><span class="stat-value">150+</span><span class="stat-label">Active Courses</span></div>
12
+ <div class="stat"><span class="stat-value">98%</span><span class="stat-label">Completion Rate</span></div>
13
+ <div class="stat"><span class="stat-value">4.9</span><span class="stat-label">Average Rating</span></div>
14
+ </div>`,
15
+ properties: {},
16
+ structure: {
17
+ container: '[data-component="stats"]',
18
+ children: {
19
+ stat: { selector: '.stat', required: true, minItems: 1 },
20
+ value: { selector: '.stat-value', parent: '.stat', required: true },
21
+ label: { selector: '.stat-label', parent: '.stat' }
22
+ }
23
+ }
24
+ };
25
+
26
+ export const metadata = {
27
+ category: 'ui-component',
28
+ cssOnly: true,
29
+ cssFile: 'components/stats.css'
30
+ };
31
+
32
+ /** No-op initializer — CSS-only component, registered for consistency. */
33
+ export function init() {}