tessera-learn 0.0.8 → 0.0.9

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.
@@ -1,5 +1,5 @@
1
1
  <script>
2
- import { getContext, onMount } from 'svelte';
2
+ import { onMount } from 'svelte';
3
3
  import { SvelteMap } from 'svelte/reactivity';
4
4
  import { useQuestion } from '../runtime/hooks.svelte.js';
5
5
  import { slugFromQuestion, shuffle } from './util.js';
@@ -19,9 +19,6 @@
19
19
  weight = 1,
20
20
  } = $props();
21
21
 
22
- const quiz = getContext('tessera-quiz');
23
- const standalone = !quiz;
24
-
25
22
  let queue = $state([]); // item indices not yet placed; queue[0] is current
26
23
  let placements = $state(new SvelteMap()); // itemIdx → targetIdx
27
24
  let dragOver = $state(null); // target index highlighted during drag
@@ -36,15 +33,6 @@
36
33
  isDragging = false;
37
34
  }
38
35
 
39
- if (standalone) {
40
- initQueue();
41
- } else {
42
- onMount(() => {
43
- initQueue();
44
- quiz.setRender(myIndex, renderQuestion);
45
- });
46
- }
47
-
48
36
  function checkAnswer(answer) {
49
37
  if (!answer || !(answer instanceof Map)) return false;
50
38
  if (answer.size !== items.length) return false;
@@ -61,7 +49,7 @@
61
49
  // Sorting is semantically a categorization (each item → one target) and maps
62
50
  // cleanly to SCORM 2004's `matching` interaction. We emit [itemIdx, targetIdx]
63
51
  // pairs as stringified ids.
64
- const handle = useQuestion({
52
+ const q = useQuestion({
65
53
  get id() { return id ?? `sorting-${slugFromQuestion(question)}`; },
66
54
  get weight() { return weight; },
67
55
  get maxRetries() { return maxRetries; },
@@ -73,13 +61,20 @@
73
61
  reset: resetState,
74
62
  });
75
63
 
76
- const myIndex = $derived(handle.quizIndex ?? -1);
64
+ // `q.mode` is fixed for the lifetime of the widget; capture it once so
65
+ // setup-time branches don't trip Svelte's "state_referenced_locally" warning.
66
+ const inQuiz = q.mode === 'quiz';
77
67
 
78
- let currentItemIdx = $derived(queue.length > 0 ? queue[0] : null);
68
+ if (!inQuiz) {
69
+ initQueue();
70
+ } else {
71
+ onMount(() => {
72
+ initQueue();
73
+ q.setRender(renderQuestion);
74
+ });
75
+ }
79
76
 
80
- let isLocked = $derived(standalone ? false : quiz.isLockedCorrect(myIndex));
81
- let isDisabled = $derived(standalone ? handle.submitted : quiz.isAnswerLocked(myIndex));
82
- let showFeedback = $derived(standalone ? handle.submitted : quiz.feedbackVisible(myIndex));
77
+ let currentItemIdx = $derived(queue.length > 0 ? queue[0] : null);
83
78
 
84
79
  function getItemsForTarget(targetIdx) {
85
80
  const result = [];
@@ -94,19 +89,22 @@
94
89
  }
95
90
 
96
91
  function placeCard(targetIdx) {
97
- if (isDisabled || currentItemIdx === null) return;
92
+ if (q.locked || currentItemIdx === null) return;
98
93
  const itemIdx = queue[0];
99
94
  placements.set(itemIdx, targetIdx);
100
95
  queue = queue.slice(1);
101
96
  cardSelected = false;
102
- if (!standalone) quiz.setAnswer(myIndex, new Map(placements));
97
+ if (inQuiz) {
98
+ q.setAnswer(new Map(placements));
99
+ if (placements.size === items.length) q.commit();
100
+ }
103
101
  }
104
102
 
105
103
  function returnCard(itemIdx) {
106
- if (isDisabled) return;
104
+ if (q.locked) return;
107
105
  placements.delete(itemIdx);
108
106
  queue = [itemIdx, ...queue];
109
- if (!standalone) quiz.setAnswer(myIndex, new Map(placements));
107
+ if (inQuiz) q.setAnswer(new Map(placements));
110
108
  }
111
109
 
112
110
  // --- Drag handlers ---
@@ -122,7 +120,7 @@
122
120
  }
123
121
 
124
122
  function onDragOver(e, targetIdx) {
125
- if (isDisabled) return;
123
+ if (q.locked) return;
126
124
  e.preventDefault();
127
125
  e.dataTransfer.dropEffect = 'move';
128
126
  dragOver = targetIdx;
@@ -145,7 +143,7 @@
145
143
  // --- Click / tap handlers ---
146
144
 
147
145
  function onCardClick() {
148
- if (isDisabled || currentItemIdx === null) return;
146
+ if (q.locked || currentItemIdx === null) return;
149
147
  cardSelected = !cardSelected;
150
148
  }
151
149
 
@@ -157,7 +155,7 @@
157
155
  }
158
156
 
159
157
  function onTargetClick(targetIdx) {
160
- if (isDisabled || !cardSelected) return;
158
+ if (q.locked || !cardSelected) return;
161
159
  placeCard(targetIdx);
162
160
  }
163
161
 
@@ -174,7 +172,7 @@
174
172
  <p class="tessera-sorting-question">{question}</p>
175
173
 
176
174
  <!-- Card deck: shows the current card to be placed -->
177
- {#if !isDisabled}
175
+ {#if !q.locked}
178
176
  <div class="tessera-sorting-deck" aria-live="polite" aria-atomic="false">
179
177
  {#if currentItemIdx !== null}
180
178
  <div class="tessera-sorting-deck-inner">
@@ -212,17 +210,17 @@
212
210
  {/if}
213
211
 
214
212
  <!-- Drop targets -->
215
- <div class="tessera-sorting-targets" class:targets-active={cardSelected && !isDisabled}>
213
+ <div class="tessera-sorting-targets" class:targets-active={cardSelected && !q.locked}>
216
214
  {#each targets as targetLabel, targetIdx}
217
215
  {@const targetItems = getItemsForTarget(targetIdx)}
218
216
  <div
219
217
  class="tessera-sorting-target"
220
218
  class:drag-over={dragOver === targetIdx}
221
- class:clickable={cardSelected && !isDisabled}
219
+ class:clickable={cardSelected && !q.locked}
222
220
  role="button"
223
221
  tabindex="0"
224
- aria-disabled={!(cardSelected && !isDisabled)}
225
- aria-label="Target: {targetLabel}{cardSelected && !isDisabled ? ` (activate to place ${items[currentItemIdx]})` : ''}"
222
+ aria-disabled={!(cardSelected && !q.locked)}
223
+ aria-label="Target: {targetLabel}{cardSelected && !q.locked ? ` (activate to place ${items[currentItemIdx]})` : ''}"
226
224
  ondragover={(e) => onDragOver(e, targetIdx)}
227
225
  ondragleave={onDragLeave}
228
226
  ondrop={(e) => onDrop(e, targetIdx)}
@@ -235,17 +233,17 @@
235
233
  {#each targetItems as itemIdx}
236
234
  <div
237
235
  class="tessera-sorting-placed-item"
238
- class:correct={showFeedback && isCorrectPlacement(itemIdx)}
239
- class:incorrect={showFeedback && !isCorrectPlacement(itemIdx)}
236
+ class:correct={q.feedbackVisible && isCorrectPlacement(itemIdx)}
237
+ class:incorrect={q.feedbackVisible && !isCorrectPlacement(itemIdx)}
240
238
  >
241
239
  <span class="tessera-sorting-item-text">{items[itemIdx]}</span>
242
- {#if !isDisabled}
240
+ {#if !q.locked}
243
241
  <button
244
242
  class="tessera-sorting-remove"
245
243
  aria-label="Return '{items[itemIdx]}' to deck"
246
244
  onclick={(e) => { e.stopPropagation(); returnCard(itemIdx); }}
247
245
  >×</button>
248
- {:else if showFeedback}
246
+ {:else if q.feedbackVisible}
249
247
  <span class="tessera-sorting-item-icon" aria-hidden="true">
250
248
  {isCorrectPlacement(itemIdx) ? '✓' : '✗'}
251
249
  </span>
@@ -259,7 +257,7 @@
259
257
  </div>
260
258
 
261
259
  <!-- Feedback (shown after standalone submit or quiz feedbackVisible) -->
262
- {#if showFeedback}
260
+ {#if q.feedbackVisible}
263
261
  {@const isCorrect = checkAnswer(placements)}
264
262
  <div class="tessera-sorting-review">
265
263
  {#if isCorrect}
@@ -285,23 +283,23 @@
285
283
  <p class="tessera-sorting-feedback incorrect">{incorrectFeedback}</p>
286
284
  {/if}
287
285
  {/if}
288
- {#if standalone && handle.canRetry}
289
- <RetryButton onclick={() => handle.retry()} />
286
+ {#if !inQuiz && q.canRetry}
287
+ <RetryButton onclick={() => q.retry()} />
290
288
  {/if}
291
289
  </div>
292
290
  {/if}
293
291
 
294
292
  <!-- Standalone Check button (shown once all cards are placed) -->
295
- {#if standalone && !handle.submitted && placements.size === items.length}
293
+ {#if !inQuiz && !q.submitted && placements.size === items.length}
296
294
  <div class="tessera-sorting-actions">
297
- <button class="tessera-btn-primary tessera-sorting-check" onclick={() => handle.submit()}>
295
+ <button class="tessera-btn-primary tessera-sorting-check" onclick={() => q.submit()}>
298
296
  Check Answer
299
297
  </button>
300
298
  </div>
301
299
  {/if}
302
300
  {/snippet}
303
301
 
304
- {#if standalone}
302
+ {#if !inQuiz}
305
303
  <div class="tessera-sorting" aria-label={question}>
306
304
  {@render sortingContent()}
307
305
  </div>
@@ -309,7 +307,7 @@
309
307
 
310
308
  {#snippet renderQuestion()}
311
309
  <div class="tessera-sorting" aria-label={question}>
312
- {#if isLocked}
310
+ {#if q.isLockedCorrect}
313
311
  <LockedBanner />
314
312
  {/if}
315
313
  {@render sortingContent()}
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export type {
48
48
  UseQuestionOptions,
49
49
  UseQuestionHandle,
50
50
  UseQuizHandle,
51
+ Question,
51
52
  } from './runtime/hooks.svelte.js';
52
53
  export type {
53
54
  XAPIConfig,
@@ -207,6 +207,11 @@ function tesseraConfigPlugin(): Plugin {
207
207
  '$assets': resolve(root, 'assets'),
208
208
  },
209
209
  },
210
+ // tessera-learn ships .ts/.svelte.ts source; Vite's dep optimizer
211
+ // doesn't run vite-plugin-svelte's preprocessor, so skip pre-bundling.
212
+ optimizeDeps: {
213
+ exclude: ['tessera-learn'],
214
+ },
210
215
  };
211
216
  },
212
217
 
@@ -542,7 +542,8 @@ function validatePageFile(
542
542
  if (
543
543
  pageConfig?.quiz &&
544
544
  !HAS_USE_QUESTION_RE.test(content) &&
545
- !HAS_QUESTION_TAG_RE.test(content)
545
+ !HAS_QUESTION_TAG_RE.test(content) &&
546
+ !HAS_LOCAL_SVELTE_IMPORT_RE.test(content)
546
547
  ) {
547
548
  warnings.push(
548
549
  `${fileRel}: quiz page has no question components or useQuestion() calls — ` +
@@ -810,7 +811,7 @@ function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): v
810
811
  }
811
812
  }
812
813
 
813
- for (const field of ['graded', 'gatesProgress', 'showFeedback']) {
814
+ for (const field of ['graded', 'gatesProgress']) {
814
815
  if (cfg[field] !== undefined && typeof cfg[field] !== 'boolean') {
815
816
  errors.push(
816
817
  `${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`
@@ -1041,6 +1042,10 @@ const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
1041
1042
  const HAS_QUESTION_TAG_RE = new RegExp(
1042
1043
  `<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])`
1043
1044
  );
1045
+ // Custom widget imported from a local `.svelte` file may wrap useQuestion.
1046
+ // Treat its presence as enough to suppress the "no questions" warning —
1047
+ // false negatives are acceptable for a heuristic that's already advisory.
1048
+ const HAS_LOCAL_SVELTE_IMPORT_RE = /from\s+['"][^'"]+\.svelte['"]/;
1044
1049
 
1045
1050
  /**
1046
1051
  * Detect ways an author file can bypass the LMS data contract. These check
@@ -177,18 +177,12 @@
177
177
  }
178
178
  }
179
179
 
180
- // ---- Quiz completion handler ----
181
180
  function handleQuizComplete(e) {
182
- const { score, interactions = [] } = e.detail;
181
+ const { score } = e.detail;
183
182
  const pageIndex = nav.currentPageIndex;
184
183
  progress.quizCompleted(pageIndex, score);
185
- for (const { id, interaction, correct } of interactions) {
186
- adapter.reportInteraction(id, interaction, correct);
187
- }
188
184
  progress.recalculateCompletion(manifest, config);
189
185
  progress.recalculateSuccess(manifest, config);
190
- // Persistence is scheduled by the version-tracking effect below; no
191
- // explicit call needed here.
192
186
  }
193
187
 
194
188
  // ---- Persistence: serialize / restore ----
@@ -403,6 +397,7 @@
403
397
  restoreState(saved);
404
398
  prevCompletionStatus = progress.completionStatus;
405
399
  prevSuccessStatus = progress.successStatus;
400
+ adapter.seedLifecycle?.(progress.completionStatus, progress.successStatus);
406
401
  }
407
402
  persistenceReady = true;
408
403
 
@@ -1,6 +1,10 @@
1
1
  import type { PersistenceAdapter, SavedState } from '../persistence.js';
2
2
  import type { Interaction } from '../interaction.js';
3
- import { formatResponse, formatCorrectPattern } from '../interaction-format.js';
3
+ import {
4
+ formatResponse,
5
+ formatCorrectPattern,
6
+ XAPI_INTERACTION_FORMAT,
7
+ } from '../interaction-format.js';
4
8
  import { formatISO8601Duration } from './retry.js';
5
9
  import { XAPIPublisher } from '../xapi/publisher.js';
6
10
  import { X_API_VERSION } from '../xapi/version.js';
@@ -139,8 +143,8 @@ export class CMI5Adapter implements PersistenceAdapter {
139
143
  #score: number | null = null;
140
144
  #durationSeconds = 0;
141
145
  #state: SavedState | null = null;
142
- #completedSent = false;
143
- #successSent = false;
146
+ #completedEmitted = false;
147
+ #lastSuccessEmitted: 'unknown' | 'passed' | 'failed' = 'unknown';
144
148
  #terminated = false;
145
149
 
146
150
  // cmi5 §8 launch params. masteryScore (when present) overrides the
@@ -240,13 +244,28 @@ export class CMI5Adapter implements PersistenceAdapter {
240
244
  // Basic credential (already base64); we don't re-encode.
241
245
  let token = '';
242
246
  if (text.startsWith('{')) {
247
+ let parsed: unknown;
243
248
  try {
244
- const parsed = JSON.parse(text);
245
- if (parsed && typeof parsed['auth-token'] === 'string') {
246
- token = parsed['auth-token'].trim();
247
- }
249
+ parsed = JSON.parse(text);
248
250
  } catch {
249
- // fall through to legacy parsing
251
+ parsed = undefined;
252
+ }
253
+ if (parsed && typeof parsed === 'object') {
254
+ const obj = parsed as Record<string, unknown>;
255
+ if (typeof obj['auth-token'] === 'string') {
256
+ token = (obj['auth-token'] as string).trim();
257
+ } else {
258
+ const code = typeof obj['error-code'] === 'string' ? obj['error-code'] : undefined;
259
+ const errText = typeof obj['error-text'] === 'string' ? obj['error-text'] : undefined;
260
+ const detail =
261
+ code !== undefined || errText !== undefined
262
+ ? ` (error-code=${code ?? 'unknown'}${errText ? `: ${errText}` : ''})`
263
+ : '';
264
+ throw new Error(
265
+ `Tessera cmi5: fetch URL returned a JSON response without an 'auth-token' field${detail}. ` +
266
+ 'The cmi5 fetch URL is single-use (§8.2.3.1); reload from the LMS to obtain a fresh launch.'
267
+ );
268
+ }
250
269
  }
251
270
  }
252
271
  if (!token) {
@@ -434,11 +453,21 @@ export class CMI5Adapter implements PersistenceAdapter {
434
453
  this.#score = Math.max(0, Math.min(100, score));
435
454
  }
436
455
 
456
+ seedLifecycle(
457
+ completion: 'incomplete' | 'complete',
458
+ success: 'unknown' | 'passed' | 'failed'
459
+ ): void {
460
+ if (completion === 'complete') this.#completedEmitted = true;
461
+ if (success === 'passed' || success === 'failed') {
462
+ this.#lastSuccessEmitted = success;
463
+ }
464
+ }
465
+
437
466
  setCompletionStatus(status: 'incomplete' | 'complete'): void {
438
- if (status !== 'complete' || this.#completedSent || !this.#publisher) return;
467
+ if (status !== 'complete' || this.#completedEmitted || !this.#publisher) return;
439
468
  // cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Completed.
440
469
  if (this.#launchMode !== 'Normal') return;
441
- this.#completedSent = true;
470
+ this.#completedEmitted = true;
442
471
  // cmi5 §9.5.1 — `score` MUST NOT appear on Completed (Passed/Failed only).
443
472
  const result: Record<string, unknown> = {
444
473
  completion: true,
@@ -457,10 +486,11 @@ export class CMI5Adapter implements PersistenceAdapter {
457
486
  }
458
487
 
459
488
  setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
460
- if (status === 'unknown' || this.#successSent || !this.#publisher) return;
489
+ if (status === 'unknown' || !this.#publisher) return;
490
+ if (status === this.#lastSuccessEmitted) return;
461
491
  // cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Passed/Failed.
462
492
  if (this.#launchMode !== 'Normal') return;
463
- this.#successSent = true;
493
+ this.#lastSuccessEmitted = status;
464
494
 
465
495
  const verb = status === 'passed' ? VERBS.passed : VERBS.failed;
466
496
  const verbName = status === 'passed' ? 'passed' : 'failed';
@@ -518,8 +548,8 @@ export class CMI5Adapter implements PersistenceAdapter {
518
548
  correct: boolean | null
519
549
  ): void {
520
550
  if (!this.#publisher) return;
521
- const response = formatResponse(interaction);
522
- const pattern = formatCorrectPattern(interaction);
551
+ const response = formatResponse(interaction, XAPI_INTERACTION_FORMAT);
552
+ const pattern = formatCorrectPattern(interaction, XAPI_INTERACTION_FORMAT);
523
553
  const definition: Record<string, unknown> = {
524
554
  type: CMI_INTERACTION_TYPE,
525
555
  interactionType: interaction.type,