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.
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/bin/cli.js +331 -0
- package/framework/assets/logo-coursecode-black.svg +14 -0
- package/framework/assets/logo-coursecode-white.svg +14 -0
- package/framework/assets/logo-coursecode.svg +14 -0
- package/framework/css/01-base.css +160 -0
- package/framework/css/02-layout.css +499 -0
- package/framework/css/accessibility.css +834 -0
- package/framework/css/components/accordions.css +710 -0
- package/framework/css/components/assessments.css +520 -0
- package/framework/css/components/audio-player.css +570 -0
- package/framework/css/components/badges.css +80 -0
- package/framework/css/components/breadcrumbs.css +87 -0
- package/framework/css/components/buttons.css +707 -0
- package/framework/css/components/callouts.css +1280 -0
- package/framework/css/components/cards.css +475 -0
- package/framework/css/components/carousel.css +193 -0
- package/framework/css/components/checkbox-group.css +123 -0
- package/framework/css/components/checklist.css +203 -0
- package/framework/css/components/collapse.css +96 -0
- package/framework/css/components/comparison.css +33 -0
- package/framework/css/components/content-image.css +36 -0
- package/framework/css/components/document-gallery.css +425 -0
- package/framework/css/components/dropdown.css +115 -0
- package/framework/css/components/embed-frame.css +142 -0
- package/framework/css/components/engagement.css +412 -0
- package/framework/css/components/features.css +35 -0
- package/framework/css/components/flip-cards.css +253 -0
- package/framework/css/components/footer.css +353 -0
- package/framework/css/components/forms.css +294 -0
- package/framework/css/components/hero.css +216 -0
- package/framework/css/components/images.css +528 -0
- package/framework/css/components/interactive-timeline.css +274 -0
- package/framework/css/components/intro-cards.css +30 -0
- package/framework/css/components/lightbox.css +666 -0
- package/framework/css/components/loading.css +65 -0
- package/framework/css/components/modals.css +235 -0
- package/framework/css/components/notifications.css +107 -0
- package/framework/css/components/quote.css +150 -0
- package/framework/css/components/sidebar.css +684 -0
- package/framework/css/components/slide-header.css +52 -0
- package/framework/css/components/spinner.css +62 -0
- package/framework/css/components/stats.css +44 -0
- package/framework/css/components/steps.css +232 -0
- package/framework/css/components/tables.css +90 -0
- package/framework/css/components/tabs.css +347 -0
- package/framework/css/components/timeline.css +154 -0
- package/framework/css/components/toggle.css +95 -0
- package/framework/css/components/tooltip.css +226 -0
- package/framework/css/components/video-player.css +438 -0
- package/framework/css/design-tokens.css +707 -0
- package/framework/css/framework.css +86 -0
- package/framework/css/interactions/accessibility.css +75 -0
- package/framework/css/interactions/base.css +92 -0
- package/framework/css/interactions/drag-drop.css +295 -0
- package/framework/css/interactions/fill-in-the-blank.css +236 -0
- package/framework/css/interactions/hotspots.css +69 -0
- package/framework/css/interactions/index.css +45 -0
- package/framework/css/interactions/interactive-image.css +359 -0
- package/framework/css/interactions/likert.css +126 -0
- package/framework/css/interactions/matching.css +354 -0
- package/framework/css/interactions/numeric-input.css +78 -0
- package/framework/css/interactions/sequencing.css +378 -0
- package/framework/css/interactions/true-false.css +177 -0
- package/framework/css/layouts/article.css +258 -0
- package/framework/css/layouts/base.css +30 -0
- package/framework/css/layouts/canvas.css +38 -0
- package/framework/css/layouts/focused.css +236 -0
- package/framework/css/layouts/index.css +29 -0
- package/framework/css/layouts/presentation.css +191 -0
- package/framework/css/layouts/traditional.css +52 -0
- package/framework/css/responsive.css +439 -0
- package/framework/css/utilities/accessibility-utils.css +59 -0
- package/framework/css/utilities/animations.css +419 -0
- package/framework/css/utilities/borders.css +72 -0
- package/framework/css/utilities/colors.css +76 -0
- package/framework/css/utilities/container.css +46 -0
- package/framework/css/utilities/decorative.css +442 -0
- package/framework/css/utilities/display.css +257 -0
- package/framework/css/utilities/flexbox.css +80 -0
- package/framework/css/utilities/grid.css +69 -0
- package/framework/css/utilities/icons.css +534 -0
- package/framework/css/utilities/lists.css +190 -0
- package/framework/css/utilities/spacing.css +167 -0
- package/framework/css/utilities/tables.css +81 -0
- package/framework/css/utilities/typography.css +159 -0
- package/framework/css/utilities/visibility.css +117 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +1773 -0
- package/framework/docs/COURSE_OUTLINE_GUIDE.md +725 -0
- package/framework/docs/COURSE_OUTLINE_TEMPLATE.md +161 -0
- package/framework/docs/DATA_MODEL.md +409 -0
- package/framework/docs/FRAMEWORK_GUIDE.md +1088 -0
- package/framework/docs/USER_GUIDE.md +583 -0
- package/framework/docs/examples/cloudflare-channel-relay.js +169 -0
- package/framework/docs/examples/cloudflare-data-worker.js +102 -0
- package/framework/docs/examples/cloudflare-error-worker.js +228 -0
- package/framework/index.html +175 -0
- package/framework/js/app/AppActions.js +410 -0
- package/framework/js/app/AppState.js +225 -0
- package/framework/js/app/AppUI.js +616 -0
- package/framework/js/assessment/AssessmentActions.js +615 -0
- package/framework/js/assessment/AssessmentFactory.js +471 -0
- package/framework/js/assessment/AssessmentState.js +322 -0
- package/framework/js/assessment/AssessmentUI.js +451 -0
- package/framework/js/automation/api-engagement.js +196 -0
- package/framework/js/automation/api-interactions.js +167 -0
- package/framework/js/automation/api.js +242 -0
- package/framework/js/automation/index.js +41 -0
- package/framework/js/components/interactions/drag-drop.js +884 -0
- package/framework/js/components/interactions/fill-in.js +535 -0
- package/framework/js/components/interactions/hotspot.js +702 -0
- package/framework/js/components/interactions/interaction-base.js +511 -0
- package/framework/js/components/interactions/likert.js +301 -0
- package/framework/js/components/interactions/matching.js +699 -0
- package/framework/js/components/interactions/multiple-choice.js +377 -0
- package/framework/js/components/interactions/numeric.js +271 -0
- package/framework/js/components/interactions/sequencing.js +423 -0
- package/framework/js/components/interactions/true-false.js +241 -0
- package/framework/js/components/ui-components/accordion.js +442 -0
- package/framework/js/components/ui-components/alert.js +88 -0
- package/framework/js/components/ui-components/audio-player.js +1193 -0
- package/framework/js/components/ui-components/callout.js +121 -0
- package/framework/js/components/ui-components/carousel.js +145 -0
- package/framework/js/components/ui-components/checkbox-group.js +87 -0
- package/framework/js/components/ui-components/checklist.js +40 -0
- package/framework/js/components/ui-components/collapse.js +114 -0
- package/framework/js/components/ui-components/comparison.js +30 -0
- package/framework/js/components/ui-components/conditional-display.js +150 -0
- package/framework/js/components/ui-components/content-image.js +41 -0
- package/framework/js/components/ui-components/dropdown.js +262 -0
- package/framework/js/components/ui-components/embed-frame.js +274 -0
- package/framework/js/components/ui-components/features.js +33 -0
- package/framework/js/components/ui-components/flip-card.js +230 -0
- package/framework/js/components/ui-components/form-validator.js +76 -0
- package/framework/js/components/ui-components/hero.js +49 -0
- package/framework/js/components/ui-components/index.js +12 -0
- package/framework/js/components/ui-components/interactive-image.js +235 -0
- package/framework/js/components/ui-components/interactive-timeline.js +285 -0
- package/framework/js/components/ui-components/intro-cards.js +35 -0
- package/framework/js/components/ui-components/lightbox.js +652 -0
- package/framework/js/components/ui-components/modal.js +386 -0
- package/framework/js/components/ui-components/notifications.js +145 -0
- package/framework/js/components/ui-components/progress.js +88 -0
- package/framework/js/components/ui-components/quote.js +41 -0
- package/framework/js/components/ui-components/stats.js +33 -0
- package/framework/js/components/ui-components/steps.js +41 -0
- package/framework/js/components/ui-components/tabs.js +255 -0
- package/framework/js/components/ui-components/timeline.js +42 -0
- package/framework/js/components/ui-components/toggle-group.js +73 -0
- package/framework/js/components/ui-components/tooltip.js +458 -0
- package/framework/js/components/ui-components/value-display.js +133 -0
- package/framework/js/components/ui-components/video-player.js +686 -0
- package/framework/js/core/component-catalog.js +121 -0
- package/framework/js/core/event-bus.js +178 -0
- package/framework/js/core/interaction-catalog.js +149 -0
- package/framework/js/dev/runtime-linter.js +1725 -0
- package/framework/js/drivers/cmi5-driver.js +768 -0
- package/framework/js/drivers/driver-factory.js +77 -0
- package/framework/js/drivers/driver-interface.js +110 -0
- package/framework/js/drivers/http-driver-base.js +241 -0
- package/framework/js/drivers/lti-driver.js +508 -0
- package/framework/js/drivers/proxy-driver.js +444 -0
- package/framework/js/drivers/scorm-12-driver.js +560 -0
- package/framework/js/drivers/scorm-2004-driver.js +775 -0
- package/framework/js/drivers/scorm-driver-base.js +112 -0
- package/framework/js/engagement/engagement-manager.js +404 -0
- package/framework/js/engagement/engagement-progress.js +191 -0
- package/framework/js/engagement/engagement-trackers.js +215 -0
- package/framework/js/engagement/requirement-strategies.js +268 -0
- package/framework/js/main.js +727 -0
- package/framework/js/managers/accessibility-manager.js +499 -0
- package/framework/js/managers/assessment-manager.js +230 -0
- package/framework/js/managers/audio-manager.js +944 -0
- package/framework/js/managers/comment-manager.js +88 -0
- package/framework/js/managers/flag-manager.js +86 -0
- package/framework/js/managers/interaction-manager.js +254 -0
- package/framework/js/managers/interaction-registry.js +96 -0
- package/framework/js/managers/objective-manager.js +423 -0
- package/framework/js/managers/score-manager.js +441 -0
- package/framework/js/managers/video-manager.js +536 -0
- package/framework/js/navigation/Breadcrumbs.js +234 -0
- package/framework/js/navigation/NavigationActions.js +1132 -0
- package/framework/js/navigation/NavigationState.js +276 -0
- package/framework/js/navigation/NavigationUI.js +574 -0
- package/framework/js/navigation/document-gallery.js +357 -0
- package/framework/js/navigation/navigation-helpers.js +175 -0
- package/framework/js/navigation/navigation-validators.js +174 -0
- package/framework/js/state/index.js +8 -0
- package/framework/js/state/lms-connection.js +482 -0
- package/framework/js/state/lms-error-utils.js +58 -0
- package/framework/js/state/state-commits.js +200 -0
- package/framework/js/state/state-domains.js +86 -0
- package/framework/js/state/state-manager.js +502 -0
- package/framework/js/state/state-validation.js +311 -0
- package/framework/js/state/transaction-log.js +41 -0
- package/framework/js/state/xapi-statement-service.js +325 -0
- package/framework/js/utilities/access-control.js +99 -0
- package/framework/js/utilities/breakpoint-manager.js +315 -0
- package/framework/js/utilities/canvas-slide.js +35 -0
- package/framework/js/utilities/conditional-display.js +388 -0
- package/framework/js/utilities/course-channel.js +214 -0
- package/framework/js/utilities/course-helpers.js +420 -0
- package/framework/js/utilities/data-reporter.js +273 -0
- package/framework/js/utilities/error-reporter.js +313 -0
- package/framework/js/utilities/hotspot-helper.js +341 -0
- package/framework/js/utilities/icons.js +348 -0
- package/framework/js/utilities/logger.js +92 -0
- package/framework/js/utilities/markdown-renderer.js +45 -0
- package/framework/js/utilities/scroll-tracker.js +68 -0
- package/framework/js/utilities/ui-initializer.js +146 -0
- package/framework/js/utilities/utilities.js +293 -0
- package/framework/js/utilities/view-manager.js +227 -0
- package/framework/js/validation/html-validators.js +422 -0
- package/framework/js/validation/scorm-validators.js +438 -0
- package/framework/js/vendor/pipwerks.js +931 -0
- package/framework/scripts/generate-narration.js +629 -0
- package/framework/scripts/tts-providers/azure-provider.js +178 -0
- package/framework/scripts/tts-providers/base-provider.js +81 -0
- package/framework/scripts/tts-providers/deepgram-provider.js +135 -0
- package/framework/scripts/tts-providers/elevenlabs-provider.js +148 -0
- package/framework/scripts/tts-providers/google-provider.js +272 -0
- package/framework/scripts/tts-providers/index.js +158 -0
- package/framework/scripts/tts-providers/openai-provider.js +143 -0
- package/framework/version.json +63 -0
- package/lib/authoring-api.js +919 -0
- package/lib/build-linter.js +450 -0
- package/lib/build-packaging.js +186 -0
- package/lib/build.js +88 -0
- package/lib/cloud.js +691 -0
- package/lib/convert.js +341 -0
- package/lib/course-parser.js +936 -0
- package/lib/course-writer.js +258 -0
- package/lib/create.js +248 -0
- package/lib/css-index.js +237 -0
- package/lib/dev.js +51 -0
- package/lib/export-content.js +1246 -0
- package/lib/headless-browser.js +413 -0
- package/lib/import.js +377 -0
- package/lib/index.js +80 -0
- package/lib/info.js +79 -0
- package/lib/interaction-formatters.js +568 -0
- package/lib/manifest/cmi5-manifest.js +63 -0
- package/lib/manifest/lti-tool-config.js +53 -0
- package/lib/manifest/manifest-factory.js +99 -0
- package/lib/manifest/scorm-12-manifest.js +61 -0
- package/lib/manifest/scorm-2004-manifest.js +94 -0
- package/lib/manifest/scorm-proxy-manifest.js +104 -0
- package/lib/manifest-parser.js +96 -0
- package/lib/mcp-prompts.js +753 -0
- package/lib/mcp-server.js +316 -0
- package/lib/narration.js +53 -0
- package/lib/pdf-structure.js +142 -0
- package/lib/preview-export.js +231 -0
- package/lib/preview-routes-api.js +662 -0
- package/lib/preview-routes-editing.js +159 -0
- package/lib/preview-routes-lms.js +230 -0
- package/lib/preview-server.js +564 -0
- package/lib/project-utils.js +269 -0
- package/lib/proxy-templates/proxy.html +68 -0
- package/lib/proxy-templates/scorm-bridge.js +112 -0
- package/lib/scaffold.js +193 -0
- package/lib/schema-extractor.js +361 -0
- package/lib/slide-source-editor.js +586 -0
- package/lib/stub-player/app-viewer.js +195 -0
- package/lib/stub-player/app.js +370 -0
- package/lib/stub-player/catalog-panel.js +312 -0
- package/lib/stub-player/config-panel.js +1303 -0
- package/lib/stub-player/content-generator.js +586 -0
- package/lib/stub-player/content-viewer.js +173 -0
- package/lib/stub-player/debug-panel.js +420 -0
- package/lib/stub-player/edit-mode.js +922 -0
- package/lib/stub-player/edit-utils.js +400 -0
- package/lib/stub-player/header-bar.js +354 -0
- package/lib/stub-player/interaction-editor.js +210 -0
- package/lib/stub-player/interactions-panel.js +565 -0
- package/lib/stub-player/lms-api.js +1094 -0
- package/lib/stub-player/login-screen.js +74 -0
- package/lib/stub-player/outline-mode.js +689 -0
- package/lib/stub-player/styles/_assessments-panel.css +245 -0
- package/lib/stub-player/styles/_base.css +89 -0
- package/lib/stub-player/styles/_catalog-icons.css +96 -0
- package/lib/stub-player/styles/_catalog-panel.css +291 -0
- package/lib/stub-player/styles/_config-panel.css +636 -0
- package/lib/stub-player/styles/_content-viewer.css +834 -0
- package/lib/stub-player/styles/_debug-panel.css +576 -0
- package/lib/stub-player/styles/_edit-mode.css +128 -0
- package/lib/stub-player/styles/_header-bar.css +343 -0
- package/lib/stub-player/styles/_interaction-editor.css +140 -0
- package/lib/stub-player/styles/_interactions-panel.css +1038 -0
- package/lib/stub-player/styles/_login-screen.css +102 -0
- package/lib/stub-player/styles/_outline-mode.css +752 -0
- package/lib/stub-player/styles.css +15 -0
- package/lib/stub-player.js +160 -0
- package/lib/test-data-reporting.js +176 -0
- package/lib/test-error-reporting.js +146 -0
- package/lib/token.js +86 -0
- package/lib/upgrade.js +257 -0
- package/lib/validation-rules.js +517 -0
- package/lib/vite-plugin-content-discovery.js +296 -0
- package/package.json +108 -0
- package/schemas/XMLSchema.dtd +402 -0
- package/schemas/adlcp_v1p3.xsd +111 -0
- package/schemas/adlnav_v1p3.xsd +61 -0
- package/schemas/adlseq_v1p3.xsd +93 -0
- package/schemas/common/anyElement.xsd +27 -0
- package/schemas/common/dataTypes.xsd +138 -0
- package/schemas/common/elementNames.xsd +767 -0
- package/schemas/common/elementTypes.xsd +786 -0
- package/schemas/common/rootElement.xsd +31 -0
- package/schemas/common/vocabTypes.xsd +345 -0
- package/schemas/common/vocabValues.xsd +257 -0
- package/schemas/datatypes.dtd +203 -0
- package/schemas/ims_xml.xsd +35 -0
- package/schemas/imscp_v1p1.xsd +368 -0
- package/schemas/imsss_v1p0.xsd +67 -0
- package/schemas/imsss_v1p0auxresource.xsd +19 -0
- package/schemas/imsss_v1p0control.xsd +20 -0
- package/schemas/imsss_v1p0delivery.xsd +17 -0
- package/schemas/imsss_v1p0limit.xsd +47 -0
- package/schemas/imsss_v1p0objective.xsd +67 -0
- package/schemas/imsss_v1p0random.xsd +16 -0
- package/schemas/imsss_v1p0rollup.xsd +46 -0
- package/schemas/imsss_v1p0seqrule.xsd +108 -0
- package/schemas/imsss_v1p0util.xsd +94 -0
- package/schemas/license.txt +17 -0
- package/schemas/lom.xsd +102 -0
- package/schemas/lomCustom.xsd +62 -0
- package/schemas/lomLoose.xsd +62 -0
- package/schemas/lomStrict.xsd +62 -0
- package/schemas/xml.xsd +81 -0
- package/template/.env.example +92 -0
- package/template/course/assets/audio/example-intro.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--compact-player.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--demo-modal.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--full-player.mp3 +0 -0
- package/template/course/assets/docs/example_md_1.md +39 -0
- package/template/course/assets/docs/example_md_2.md +41 -0
- package/template/course/assets/docs/example_pdf_1_thumbnail.png +0 -0
- package/template/course/assets/docs/example_pdf_2.pdf +0 -0
- package/template/course/assets/images/course-architecture.svg +36 -0
- package/template/course/assets/images/logo.svg +14 -0
- package/template/course/assets/widgets/counter-demo.html +190 -0
- package/template/course/assets/widgets/gravity-painter.html +384 -0
- package/template/course/course-config.js +539 -0
- package/template/course/icons.js +19 -0
- package/template/course/interactions/PLUGIN_GUIDE.md +97 -0
- package/template/course/slides/example-course-structure.js +138 -0
- package/template/course/slides/example-final-exam.js +144 -0
- package/template/course/slides/example-finishing.js +127 -0
- package/template/course/slides/example-interactions-showcase.js +615 -0
- package/template/course/slides/example-preview-tour.js +129 -0
- package/template/course/slides/example-remedial.js +143 -0
- package/template/course/slides/example-summary.js +103 -0
- package/template/course/slides/example-ui-showcase.js +1805 -0
- package/template/course/slides/example-welcome.js +123 -0
- package/template/course/slides/example-workflow.js +140 -0
- package/template/course/theme.css +165 -0
- package/template/eslint.config.js +47 -0
- package/template/package.json +28 -0
- 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
|
+
}
|