tessera-learn 0.0.10 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +1 -0
  2. package/dist/audit-BBJpQGqb.js +204 -0
  3. package/dist/audit-BBJpQGqb.js.map +1 -0
  4. package/dist/plugin/a11y-cli.d.ts +1 -0
  5. package/dist/plugin/a11y-cli.js +36 -0
  6. package/dist/plugin/a11y-cli.js.map +1 -0
  7. package/dist/plugin/cli.js +6 -3
  8. package/dist/plugin/cli.js.map +1 -1
  9. package/dist/plugin/index.d.ts +16 -1
  10. package/dist/plugin/index.d.ts.map +1 -1
  11. package/dist/plugin/index.js +171 -140
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-BxWAMMnJ.js → validation-B-xTvM9B.js} +417 -81
  14. package/dist/validation-B-xTvM9B.js.map +1 -0
  15. package/package.json +17 -2
  16. package/src/components/Accordion.svelte +3 -1
  17. package/src/components/AccordionItem.svelte +1 -5
  18. package/src/components/Audio.svelte +22 -5
  19. package/src/components/Callout.svelte +5 -1
  20. package/src/components/Carousel.svelte +24 -8
  21. package/src/components/DefaultLayout.svelte +41 -12
  22. package/src/components/FillInTheBlank.svelte +75 -103
  23. package/src/components/Image.svelte +14 -10
  24. package/src/components/LockedBanner.svelte +5 -5
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +81 -102
  28. package/src/components/Quiz.svelte +63 -21
  29. package/src/components/ResultIcon.svelte +20 -4
  30. package/src/components/RevealModal.svelte +25 -22
  31. package/src/components/Sorting.svelte +61 -26
  32. package/src/components/Transcript.svelte +37 -0
  33. package/src/components/Video.svelte +25 -20
  34. package/src/components/util.ts +4 -1
  35. package/src/components/video-embed.ts +25 -0
  36. package/src/index.ts +2 -7
  37. package/src/plugin/a11y/audit.ts +299 -0
  38. package/src/plugin/a11y/contrast.ts +67 -0
  39. package/src/plugin/a11y-cli.ts +35 -0
  40. package/src/plugin/cli.ts +6 -8
  41. package/src/plugin/export.ts +60 -50
  42. package/src/plugin/index.ts +244 -101
  43. package/src/plugin/layout.ts +6 -51
  44. package/src/plugin/manifest.ts +90 -24
  45. package/src/plugin/override-plugin.ts +68 -0
  46. package/src/plugin/quiz.ts +9 -54
  47. package/src/plugin/validation.ts +768 -183
  48. package/src/runtime/App.svelte +128 -64
  49. package/src/runtime/LoadingBar.svelte +12 -3
  50. package/src/runtime/Sidebar.svelte +24 -8
  51. package/src/runtime/access.ts +15 -3
  52. package/src/runtime/adapters/cmi5.ts +68 -116
  53. package/src/runtime/adapters/format.ts +67 -0
  54. package/src/runtime/adapters/index.ts +45 -34
  55. package/src/runtime/adapters/retry.ts +25 -84
  56. package/src/runtime/adapters/scorm-base.ts +19 -15
  57. package/src/runtime/adapters/scorm12.ts +8 -9
  58. package/src/runtime/adapters/scorm2004.ts +22 -30
  59. package/src/runtime/adapters/web.ts +1 -1
  60. package/src/runtime/hooks.svelte.ts +152 -328
  61. package/src/runtime/interaction-format.ts +30 -12
  62. package/src/runtime/interaction.ts +44 -11
  63. package/src/runtime/navigation.svelte.ts +29 -40
  64. package/src/runtime/persistence.ts +2 -2
  65. package/src/runtime/progress.svelte.ts +22 -9
  66. package/src/runtime/quiz-engine.svelte.ts +361 -0
  67. package/src/runtime/quiz-policy.ts +28 -179
  68. package/src/runtime/types.ts +24 -2
  69. package/src/runtime/xapi/agent-rules.ts +11 -3
  70. package/src/runtime/xapi/client.ts +5 -5
  71. package/src/runtime/xapi/derive-actor.ts +2 -2
  72. package/src/runtime/xapi/publisher.ts +33 -40
  73. package/src/runtime/xapi/setup.ts +18 -15
  74. package/src/runtime/xapi/validation.ts +15 -6
  75. package/src/virtual.d.ts +4 -1
  76. package/styles/base.css +32 -11
  77. package/styles/layout.css +39 -18
  78. package/styles/theme.css +15 -3
  79. package/dist/validation-BxWAMMnJ.js.map +0 -1
@@ -19,15 +19,15 @@
19
19
  weight = 1,
20
20
  } = $props();
21
21
 
22
- let queue = $state([]); // item indices not yet placed; queue[0] is current
23
- let placements = $state(new SvelteMap()); // itemIdx → targetIdx
24
- let dragOver = $state(null); // target index highlighted during drag
22
+ let queue = $state([]); // item indices not yet placed; queue[0] is current
23
+ const placements = new SvelteMap(); // itemIdx → targetIdx
24
+ let dragOver = $state(null); // target index highlighted during drag
25
25
  let isDragging = $state(false);
26
- let cardSelected = $state(false); // current card selected via tap/click
26
+ let cardSelected = $state(false); // current card selected via tap/click
27
27
 
28
28
  function initQueue() {
29
29
  queue = shuffle(items.map((_, i) => i));
30
- placements = new SvelteMap();
30
+ placements.clear();
31
31
  cardSelected = false;
32
32
  dragOver = null;
33
33
  isDragging = false;
@@ -50,12 +50,21 @@
50
50
  // cleanly to SCORM 2004's `matching` interaction. We emit [itemIdx, targetIdx]
51
51
  // pairs as stringified ids.
52
52
  const q = useQuestion({
53
- get id() { return id ?? `sorting-${slugFromQuestion(question)}`; },
54
- get weight() { return weight; },
55
- get maxRetries() { return maxRetries; },
53
+ get id() {
54
+ return id ?? `sorting-${slugFromQuestion(question)}`;
55
+ },
56
+ get weight() {
57
+ return weight;
58
+ },
59
+ get maxRetries() {
60
+ return maxRetries;
61
+ },
56
62
  response: () => ({
57
63
  type: 'matching',
58
- response: [...placements.entries()].map(([i, t]) => [String(i), String(t)]),
64
+ response: [...placements.entries()].map(([i, t]) => [
65
+ String(i),
66
+ String(t),
67
+ ]),
59
68
  correct: items.map((_, i) => [String(i), String(correct[i])]),
60
69
  }),
61
70
  reset: resetState,
@@ -165,7 +174,6 @@
165
174
  onTargetClick(targetIdx);
166
175
  }
167
176
  }
168
-
169
177
  </script>
170
178
 
171
179
  {#snippet sortingContent()}
@@ -198,7 +206,9 @@
198
206
  {queue.length} of {items.length} to sort
199
207
  </p>
200
208
  {#if cardSelected}
201
- <p class="tessera-sorting-hint">Click a target below to place this card</p>
209
+ <p class="tessera-sorting-hint">
210
+ Click a target below to place this card
211
+ </p>
202
212
  {/if}
203
213
  </div>
204
214
  {:else}
@@ -210,8 +220,11 @@
210
220
  {/if}
211
221
 
212
222
  <!-- Drop targets -->
213
- <div class="tessera-sorting-targets" class:targets-active={cardSelected && !q.locked}>
214
- {#each targets as targetLabel, targetIdx}
223
+ <div
224
+ class="tessera-sorting-targets"
225
+ class:targets-active={cardSelected && !q.locked}
226
+ >
227
+ {#each targets as targetLabel, targetIdx (targetIdx)}
215
228
  {@const targetItems = getItemsForTarget(targetIdx)}
216
229
  <div
217
230
  class="tessera-sorting-target"
@@ -220,7 +233,9 @@
220
233
  role="button"
221
234
  tabindex="0"
222
235
  aria-disabled={!(cardSelected && !q.locked)}
223
- aria-label="Target: {targetLabel}{cardSelected && !q.locked ? ` (activate to place ${items[currentItemIdx]})` : ''}"
236
+ aria-label="Target: {targetLabel}{cardSelected && !q.locked
237
+ ? ` (activate to place ${items[currentItemIdx]})`
238
+ : ''}"
224
239
  ondragover={(e) => onDragOver(e, targetIdx)}
225
240
  ondragleave={onDragLeave}
226
241
  ondrop={(e) => onDrop(e, targetIdx)}
@@ -230,19 +245,23 @@
230
245
  <div class="tessera-sorting-target-label">{targetLabel}</div>
231
246
  {#if targetItems.length > 0}
232
247
  <div class="tessera-sorting-target-items">
233
- {#each targetItems as itemIdx}
248
+ {#each targetItems as itemIdx (itemIdx)}
234
249
  <div
235
250
  class="tessera-sorting-placed-item"
236
251
  class:correct={q.feedbackVisible && isCorrectPlacement(itemIdx)}
237
- class:incorrect={q.feedbackVisible && !isCorrectPlacement(itemIdx)}
252
+ class:incorrect={q.feedbackVisible &&
253
+ !isCorrectPlacement(itemIdx)}
238
254
  >
239
255
  <span class="tessera-sorting-item-text">{items[itemIdx]}</span>
240
256
  {#if !q.locked}
241
257
  <button
242
258
  class="tessera-sorting-remove"
243
259
  aria-label="Return '{items[itemIdx]}' to deck"
244
- onclick={(e) => { e.stopPropagation(); returnCard(itemIdx); }}
245
- >×</button>
260
+ onclick={(e) => {
261
+ e.stopPropagation();
262
+ returnCard(itemIdx);
263
+ }}>×</button
264
+ >
246
265
  {:else if q.feedbackVisible}
247
266
  <span class="tessera-sorting-item-icon" aria-hidden="true">
248
267
  {isCorrectPlacement(itemIdx) ? '✓' : '✗'}
@@ -275,8 +294,10 @@
275
294
  </div>
276
295
  <div class="tessera-sorting-correct-list">
277
296
  <p class="tessera-sorting-correct-title">Correct arrangement:</p>
278
- {#each items as item, i}
279
- <p class="tessera-sorting-correct-item">{item} → {targets[correct[i]]}</p>
297
+ {#each items as item, i (i)}
298
+ <p class="tessera-sorting-correct-item">
299
+ {item} → {targets[correct[i]]}
300
+ </p>
280
301
  {/each}
281
302
  </div>
282
303
  {#if incorrectFeedback}
@@ -292,7 +313,10 @@
292
313
  <!-- Standalone Check button (shown once all cards are placed) -->
293
314
  {#if !inQuiz && !q.submitted && placements.size === items.length}
294
315
  <div class="tessera-sorting-actions">
295
- <button class="tessera-btn-primary tessera-sorting-check" onclick={() => q.submit()}>
316
+ <button
317
+ class="tessera-btn-primary tessera-sorting-check"
318
+ onclick={() => q.submit()}
319
+ >
296
320
  Check Answer
297
321
  </button>
298
322
  </div>
@@ -359,7 +383,10 @@
359
383
  font-family: var(--tessera-font-family);
360
384
  color: var(--tessera-text);
361
385
  cursor: grab;
362
- transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
386
+ transition:
387
+ border-color 0.15s,
388
+ transform 0.15s,
389
+ box-shadow 0.15s;
363
390
  text-align: center;
364
391
  user-select: none;
365
392
  }
@@ -424,7 +451,9 @@
424
451
  border: 2px dashed var(--tessera-border);
425
452
  border-radius: 10px;
426
453
  background: var(--tessera-bg-secondary);
427
- transition: border-color 0.15s, background 0.15s;
454
+ transition:
455
+ border-color 0.15s,
456
+ background 0.15s;
428
457
  overflow: hidden;
429
458
  display: flex;
430
459
  flex-direction: column;
@@ -478,7 +507,9 @@
478
507
  font-size: 0.875rem;
479
508
  font-family: var(--tessera-font-family);
480
509
  color: var(--tessera-text);
481
- transition: border-color 0.15s, background 0.15s;
510
+ transition:
511
+ border-color 0.15s,
512
+ background 0.15s;
482
513
  pointer-events: all;
483
514
  }
484
515
 
@@ -550,8 +581,12 @@
550
581
  margin-bottom: var(--tessera-spacing-sm);
551
582
  }
552
583
 
553
- .tessera-sorting-result.correct { color: var(--tessera-success); }
554
- .tessera-sorting-result.incorrect { color: var(--tessera-error); }
584
+ .tessera-sorting-result.correct {
585
+ color: var(--tessera-success);
586
+ }
587
+ .tessera-sorting-result.incorrect {
588
+ color: var(--tessera-error);
589
+ }
555
590
 
556
591
  .tessera-sorting-correct-list {
557
592
  margin: var(--tessera-spacing-sm) 0;
@@ -0,0 +1,37 @@
1
+ <script>
2
+ /**
3
+ * @component Transcript
4
+ * Shared transcript disclosure for Audio/Video (WCAG 1.2). Renders nothing
5
+ * when empty.
6
+ *
7
+ * @prop {string} [text] - Transcript text shown in a <details> disclosure.
8
+ */
9
+ let { text = '' } = $props();
10
+ </script>
11
+
12
+ {#if text}
13
+ <details class="tessera-transcript">
14
+ <summary>Transcript</summary>
15
+ <div class="tessera-transcript-body">{text}</div>
16
+ </details>
17
+ {/if}
18
+
19
+ <style>
20
+ .tessera-transcript {
21
+ margin-top: var(--tessera-spacing-sm);
22
+ margin-bottom: var(--tessera-spacing-lg);
23
+ font-size: 0.875rem;
24
+ }
25
+
26
+ .tessera-transcript summary {
27
+ cursor: pointer;
28
+ font-weight: 600;
29
+ color: var(--tessera-text);
30
+ }
31
+
32
+ .tessera-transcript-body {
33
+ margin-top: var(--tessera-spacing-sm);
34
+ color: var(--tessera-text-light);
35
+ white-space: pre-line;
36
+ }
37
+ </style>
@@ -4,28 +4,27 @@
4
4
  * Embeds YouTube/Vimeo via iframe or local video files.
5
5
  * Lazy-loads via IntersectionObserver.
6
6
  *
7
- * @prop {string} src - Video URL (YouTube, Vimeo, or direct video file)
8
- * @prop {string} [title] - Accessible label for the video
7
+ * @prop {string} src - Video URL (YouTube, Vimeo, direct video file, or $assets/ path)
8
+ * @prop {string} title - Accessible label for the video (required; rule 1.4)
9
+ * @prop {Array<{ src: string, kind?: 'captions'|'subtitles', srclang?: string, label?: string }>} [tracks] -
10
+ * Caption/subtitle tracks for native (non-embed) video, rendered as <track>.
11
+ * Ignored for YouTube/Vimeo embeds — the platform owns their captions.
12
+ * @prop {string} [transcript] - Transcript text shown in a <details> disclosure
13
+ * below the player. To load it from a file, import the file with Vite's ?raw
14
+ * suffix: `import t from '$assets/x.txt?raw'` then `transcript={t}`.
9
15
  */
10
16
  import { onMount } from 'svelte';
17
+ import { resolveAsset } from './util.js';
18
+ import { resolveVideoEmbedUrl } from './video-embed.js';
19
+ import MediaTracks from './MediaTracks.svelte';
20
+ import Transcript from './Transcript.svelte';
11
21
 
12
- let { src, title = '' } = $props();
22
+ let { src, title, tracks = [], transcript = '' } = $props();
23
+ let resolvedSrc = $derived(resolveAsset(src));
13
24
  let containerRef = $state(null);
14
25
  let visible = $state(false);
15
26
 
16
- const youtubeRegex = /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
17
- const vimeoRegex = /vimeo\.com\/(?:video\/)?(\d+)/;
18
-
19
- let embedUrl = $derived.by(() => {
20
- const ytMatch = src.match(youtubeRegex);
21
- if (ytMatch) return `https://www.youtube.com/embed/${ytMatch[1]}`;
22
-
23
- const vimeoMatch = src.match(vimeoRegex);
24
- if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
25
-
26
- return null;
27
- });
28
-
27
+ let embedUrl = $derived(resolveVideoEmbedUrl(src));
29
28
  let isEmbed = $derived(embedUrl !== null);
30
29
 
31
30
  onMount(() => {
@@ -38,7 +37,7 @@
38
37
  observer.disconnect();
39
38
  }
40
39
  },
41
- { rootMargin: '200px' }
40
+ { rootMargin: '200px' },
42
41
  );
43
42
 
44
43
  observer.observe(containerRef);
@@ -46,7 +45,11 @@
46
45
  });
47
46
  </script>
48
47
 
49
- <div class="tessera-video" bind:this={containerRef} aria-label={title || 'Video'}>
48
+ <div
49
+ class="tessera-video"
50
+ bind:this={containerRef}
51
+ aria-label={title || 'Video'}
52
+ >
50
53
  {#if visible}
51
54
  {#if isEmbed}
52
55
  <div class="tessera-video-embed">
@@ -59,9 +62,9 @@
59
62
  ></iframe>
60
63
  </div>
61
64
  {:else}
62
- <!-- svelte-ignore a11y_media_has_caption -->
63
65
  <video controls class="tessera-video-native" aria-label={title}>
64
- <source {src} />
66
+ <source src={resolvedSrc} />
67
+ <MediaTracks {tracks} />
65
68
  Your browser does not support the video element.
66
69
  </video>
67
70
  {/if}
@@ -72,6 +75,8 @@
72
75
  {/if}
73
76
  </div>
74
77
 
78
+ <Transcript text={transcript} />
79
+
75
80
  <style>
76
81
  .tessera-video {
77
82
  margin-bottom: var(--tessera-spacing-lg);
@@ -7,7 +7,10 @@
7
7
  * same alias semantics.
8
8
  */
9
9
  export function resolveAsset(src: string): string {
10
- return src.startsWith('$assets/') ? src.replace('$assets/', './assets/') : src;
10
+ if (!src) return src;
11
+ return src.startsWith('$assets/')
12
+ ? src.replace('$assets/', './assets/')
13
+ : src;
11
14
  }
12
15
 
13
16
  /**
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Shared YouTube/Vimeo embed detection. Used by Video.svelte to pick the iframe
3
+ * vs native-<video> render path, and by the Tier-1b linter (rule 1.4) so its
4
+ * caption/transcript guidance matches what the component actually renders.
5
+ */
6
+
7
+ const YOUTUBE_RE =
8
+ /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
9
+ const VIMEO_RE = /vimeo\.com\/(?:video\/)?(\d+)/;
10
+
11
+ /** Resolve a source URL to its embed URL, or null if it's not a known embed. */
12
+ export function resolveVideoEmbedUrl(src: string): string | null {
13
+ const yt = src.match(YOUTUBE_RE);
14
+ if (yt) return `https://www.youtube.com/embed/${yt[1]}`;
15
+
16
+ const vimeo = src.match(VIMEO_RE);
17
+ if (vimeo) return `https://player.vimeo.com/video/${vimeo[1]}`;
18
+
19
+ return null;
20
+ }
21
+
22
+ /** True when the component will render an iframe embed rather than <video>. */
23
+ export function isVideoEmbed(src: string): boolean {
24
+ return resolveVideoEmbedUrl(src) !== null;
25
+ }
package/src/index.ts CHANGED
@@ -18,10 +18,7 @@ export {
18
18
  sequentialAccess,
19
19
  resolveAccess,
20
20
  } from './runtime/access.js';
21
- export type {
22
- AccessFn,
23
- AccessContext,
24
- } from './runtime/access.js';
21
+ export type { AccessFn, AccessContext } from './runtime/access.js';
25
22
 
26
23
  // ---- xAPI ----
27
24
  export { useXAPI } from './runtime/xapi/registry.js';
@@ -40,9 +37,7 @@ export type {
40
37
  } from './runtime/xapi/types.js';
41
38
 
42
39
  // ---- Types ----
43
- export type {
44
- Interaction,
45
- } from './runtime/interaction.js';
40
+ export type { Interaction } from './runtime/interaction.js';
46
41
  export { isCorrect } from './runtime/interaction.js';
47
42
  export type {
48
43
  UseQuestionOptions,
@@ -0,0 +1,299 @@
1
+ import { existsSync, writeFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { generateManifest, readCourseConfig } from '../manifest.js';
4
+ import { normalizeA11y, type A11ySettings } from '../validation.js';
5
+
6
+ export interface AuditOptions {
7
+ /** Minimum violation impact that fails the run (CI gate). Default 'serious'. */
8
+ threshold?: ImpactLevel;
9
+ /** Force a fresh `vite build` even if dist/ exists. */
10
+ rebuild?: boolean;
11
+ }
12
+
13
+ export type ImpactLevel = 'minor' | 'moderate' | 'serious' | 'critical';
14
+
15
+ const IMPACT_RANK: Record<ImpactLevel, number> = {
16
+ minor: 1,
17
+ moderate: 2,
18
+ serious: 3,
19
+ critical: 4,
20
+ };
21
+
22
+ // Set by runAudit during its build/preview; the plugin forces the WebAdapter,
23
+ // skips export packaging, and stubs xAPI while it's set. See plugin/index.ts.
24
+ export const AUDIT_ENV_FLAG = 'TESSERA_A11Y_AUDIT';
25
+
26
+ interface AxeViolation {
27
+ id: string;
28
+ impact: ImpactLevel | null;
29
+ help: string;
30
+ helpUrl: string;
31
+ nodes: number;
32
+ }
33
+
34
+ interface PageAuditResult {
35
+ index: number;
36
+ title: string;
37
+ violations: AxeViolation[];
38
+ }
39
+
40
+ interface AuditReport {
41
+ standard: A11ySettings['standard'];
42
+ threshold: ImpactLevel;
43
+ pages: PageAuditResult[];
44
+ totalViolations: number;
45
+ failingViolations: number;
46
+ passed: boolean;
47
+ }
48
+
49
+ /** Map the `a11y.standard` enum to axe's cumulative `runOnly` tag list. */
50
+ export function axeTags(standard: A11ySettings['standard']): string[] {
51
+ switch (standard) {
52
+ case 'wcag2a':
53
+ return ['wcag2a'];
54
+ case 'wcag21aa':
55
+ return ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
56
+ case 'wcag2aa':
57
+ default:
58
+ return ['wcag2a', 'wcag2aa'];
59
+ }
60
+ }
61
+
62
+ /** axe-applicable ignore entries: drop the Tier-1a/1b namespaces. */
63
+ export function axeIgnoreRules(ignore: string[]): string[] {
64
+ return ignore.filter(
65
+ (id) => !id.startsWith('tessera/') && !id.startsWith('a11y_'),
66
+ );
67
+ }
68
+
69
+ // A violation with no impact is treated as failing rather than slipping the
70
+ // gate at every threshold.
71
+ function isFailing(v: AxeViolation, thresholdRank: number): boolean {
72
+ return !v.impact || IMPACT_RANK[v.impact] >= thresholdRank;
73
+ }
74
+
75
+ // Optional deps loaded by variable specifier so tsc doesn't require them to be
76
+ // installed — Tier 2 is opt-in and the absence is handled with a clear message.
77
+ async function tryImport(specifier: string): Promise<unknown> {
78
+ return import(specifier);
79
+ }
80
+
81
+ interface LoadedDeps {
82
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
83
+ chromium: any;
84
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
85
+ AxeBuilder: any;
86
+ }
87
+
88
+ async function loadDeps(): Promise<
89
+ { ok: true; deps: LoadedDeps } | { ok: false; missing: string }
90
+ > {
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
92
+ let chromium: any;
93
+ for (const spec of ['playwright', '@playwright/test']) {
94
+ try {
95
+ const mod = (await tryImport(spec)) as { chromium?: unknown };
96
+ if (mod.chromium) {
97
+ chromium = mod.chromium;
98
+ break;
99
+ }
100
+ } catch {
101
+ // try the next specifier
102
+ }
103
+ }
104
+ if (!chromium) return { ok: false, missing: 'playwright' };
105
+
106
+ try {
107
+ const mod = (await tryImport('@axe-core/playwright')) as {
108
+ default?: unknown;
109
+ };
110
+ if (!mod.default) return { ok: false, missing: '@axe-core/playwright' };
111
+ return { ok: true, deps: { chromium, AxeBuilder: mod.default } };
112
+ } catch {
113
+ return { ok: false, missing: '@axe-core/playwright' };
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Run the Tier-2 runtime accessibility audit against a built course. Builds (or
119
+ * reuses) dist/, serves it, drives Playwright + axe-core over each page, writes
120
+ * a11y-report.json, and returns a process exit code (0 pass, 1 fail/error).
121
+ */
122
+ export async function runAudit(
123
+ projectRoot: string,
124
+ options: AuditOptions = {},
125
+ ): Promise<number> {
126
+ const threshold: ImpactLevel = options.threshold ?? 'serious';
127
+
128
+ const deps = await loadDeps();
129
+ if (!deps.ok) {
130
+ console.error(
131
+ `\x1b[31m[tessera a11y]\x1b[0m Tier 2 needs Playwright + axe-core, which aren't installed.\n` +
132
+ ` Install them to run the runtime audit:\n` +
133
+ ` npm i -D playwright @axe-core/playwright\n` +
134
+ ` npx playwright install chromium`,
135
+ );
136
+ return 1;
137
+ }
138
+ const { chromium, AxeBuilder } = deps.deps;
139
+
140
+ const read = readCourseConfig(projectRoot);
141
+ const settings = normalizeA11y(read.ok ? read.config.a11y : undefined);
142
+ const tags = axeTags(settings.standard);
143
+ const disableRules = axeIgnoreRules(settings.ignore);
144
+
145
+ const manifest = generateManifest(resolve(projectRoot, 'pages'));
146
+
147
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
148
+ const vite = (await import('vite')) as any;
149
+
150
+ // A throwaway web build, kept out of dist/ so a real LMS export is untouched.
151
+ const auditDist = resolve(projectRoot, 'node_modules', '.tessera-a11y');
152
+ const distHtml = resolve(auditDist, 'index.html');
153
+
154
+ const prevEnv = process.env[AUDIT_ENV_FLAG];
155
+ process.env[AUDIT_ENV_FLAG] = '1';
156
+
157
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
158
+ let server: any;
159
+ try {
160
+ if (options.rebuild || !existsSync(distHtml)) {
161
+ console.log('[tessera a11y] Building course…');
162
+ await vite.build({
163
+ root: projectRoot,
164
+ build: { outDir: auditDist, emptyOutDir: true },
165
+ logLevel: 'warn',
166
+ });
167
+ }
168
+
169
+ server = await vite.preview({
170
+ root: projectRoot,
171
+ build: { outDir: auditDist },
172
+ preview: { port: 0, host: '127.0.0.1' },
173
+ logLevel: 'warn',
174
+ });
175
+ const baseUrl: string | undefined = server.resolvedUrls?.local?.[0];
176
+ if (!baseUrl) {
177
+ console.error('[tessera a11y] Could not determine preview server URL.');
178
+ return 1;
179
+ }
180
+
181
+ const browser = await chromium.launch();
182
+ const pages: PageAuditResult[] = [];
183
+ try {
184
+ // axe-core/playwright requires a page from an explicit context.
185
+ const context = await browser.newContext();
186
+ const page = await context.newPage();
187
+ // ?__tessera_audit unlocks navigation so quiz-gated pages can be scanned.
188
+ const auditUrl = new URL(baseUrl);
189
+ auditUrl.searchParams.set('__tessera_audit', '1');
190
+ await page.goto(auditUrl.href, { waitUntil: 'networkidle' });
191
+ await page.waitForSelector('#tessera-app', { timeout: 20_000 });
192
+
193
+ const scan = async (): Promise<AxeViolation[]> => {
194
+ const builder = new AxeBuilder({ page }).withTags(tags);
195
+ if (disableRules.length > 0) builder.disableRules(disableRules);
196
+ const out = await builder.analyze();
197
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
198
+ return out.violations.map((v: any) => ({
199
+ id: v.id,
200
+ impact: v.impact ?? null,
201
+ help: v.help,
202
+ helpUrl: v.helpUrl,
203
+ nodes: v.nodes.length,
204
+ }));
205
+ };
206
+
207
+ const navCount = await page.locator('button.tessera-nav-page').count();
208
+ if (navCount === 0) {
209
+ // No sidebar (custom chrome) — audit whatever is rendered at the entry.
210
+ pages.push({
211
+ index: 0,
212
+ title: manifest.pages[0]?.title ?? '(entry)',
213
+ violations: await scan(),
214
+ });
215
+ } else {
216
+ for (let i = 0; i < navCount; i++) {
217
+ const btn = page.locator('button.tessera-nav-page').nth(i);
218
+ const title = (await btn.textContent())?.trim() || `Page ${i + 1}`;
219
+ await btn.click();
220
+ await page.waitForFunction(
221
+ (idx: number) =>
222
+ document
223
+ .querySelectorAll('button.tessera-nav-page')
224
+ [idx]?.getAttribute('aria-current') === 'page',
225
+ i,
226
+ { timeout: 20_000 },
227
+ );
228
+ await page.waitForLoadState('networkidle');
229
+ pages.push({ index: i, title, violations: await scan() });
230
+ }
231
+ }
232
+ } finally {
233
+ await browser.close();
234
+ }
235
+
236
+ const thresholdRank = IMPACT_RANK[threshold];
237
+ let totalViolations = 0;
238
+ let failingViolations = 0;
239
+ for (const p of pages) {
240
+ for (const v of p.violations) {
241
+ totalViolations++;
242
+ if (isFailing(v, thresholdRank)) failingViolations++;
243
+ }
244
+ }
245
+
246
+ const report: AuditReport = {
247
+ standard: settings.standard,
248
+ threshold,
249
+ pages,
250
+ totalViolations,
251
+ failingViolations,
252
+ passed: failingViolations === 0,
253
+ };
254
+ const reportPath = resolve(projectRoot, 'a11y-report.json');
255
+ writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf-8');
256
+
257
+ printSummary(report, reportPath);
258
+ return report.passed ? 0 : 1;
259
+ } catch (err) {
260
+ console.error(
261
+ `\x1b[31m[tessera a11y]\x1b[0m Audit could not complete: ${
262
+ err instanceof Error ? err.message : String(err)
263
+ }`,
264
+ );
265
+ return 1;
266
+ } finally {
267
+ server?.httpServer?.close?.();
268
+ if (prevEnv === undefined) delete process.env[AUDIT_ENV_FLAG];
269
+ else process.env[AUDIT_ENV_FLAG] = prevEnv;
270
+ }
271
+ }
272
+
273
+ function printSummary(report: AuditReport, reportPath: string): void {
274
+ const thresholdRank = IMPACT_RANK[report.threshold];
275
+ for (const p of report.pages) {
276
+ if (p.violations.length === 0) {
277
+ console.log(`\x1b[32m ✓\x1b[0m ${p.title}`);
278
+ continue;
279
+ }
280
+ const failing = p.violations.some((v) => isFailing(v, thresholdRank));
281
+ const mark = failing ? '\x1b[31m ✗\x1b[0m' : '\x1b[33m ⚠\x1b[0m';
282
+ console.log(`${mark} ${p.title}`);
283
+ for (const v of p.violations) {
284
+ console.log(
285
+ ` [${v.impact ?? 'n/a'}] ${v.id} — ${v.help} (${v.nodes} node${v.nodes === 1 ? '' : 's'})`,
286
+ );
287
+ }
288
+ }
289
+ console.log(`\n[tessera a11y] Report written to ${reportPath}`);
290
+ if (report.passed) {
291
+ console.log(
292
+ `\x1b[32m[tessera a11y] Passed\x1b[0m — ${report.totalViolations} total finding(s), none at/above "${report.threshold}".`,
293
+ );
294
+ } else {
295
+ console.log(
296
+ `\x1b[31m[tessera a11y] Failed\x1b[0m — ${report.failingViolations} finding(s) at/above "${report.threshold}" (of ${report.totalViolations} total).`,
297
+ );
298
+ }
299
+ }