tessera-learn 0.0.1

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 (71) hide show
  1. package/AGENTS.md +1228 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -0
  4. package/dist/plugin/index.d.ts +7 -0
  5. package/dist/plugin/index.d.ts.map +1 -0
  6. package/dist/plugin/index.js +1239 -0
  7. package/dist/plugin/index.js.map +1 -0
  8. package/package.json +77 -0
  9. package/src/archiver.d.ts +27 -0
  10. package/src/components/Accordion.svelte +32 -0
  11. package/src/components/AccordionItem.svelte +144 -0
  12. package/src/components/Audio.svelte +38 -0
  13. package/src/components/Callout.svelte +81 -0
  14. package/src/components/Carousel.svelte +194 -0
  15. package/src/components/CarouselSlide.svelte +32 -0
  16. package/src/components/DefaultLayout.svelte +108 -0
  17. package/src/components/FillInTheBlank.svelte +345 -0
  18. package/src/components/Image.svelte +47 -0
  19. package/src/components/Matching.svelte +513 -0
  20. package/src/components/MultipleChoice.svelte +363 -0
  21. package/src/components/Quiz.svelte +569 -0
  22. package/src/components/RevealModal.svelte +228 -0
  23. package/src/components/Sorting.svelte +663 -0
  24. package/src/components/Video.svelte +118 -0
  25. package/src/components/index.ts +15 -0
  26. package/src/components/quiz-payload.ts +71 -0
  27. package/src/components/util.ts +24 -0
  28. package/src/index.ts +56 -0
  29. package/src/plugin/export.ts +264 -0
  30. package/src/plugin/index.ts +464 -0
  31. package/src/plugin/layout.ts +55 -0
  32. package/src/plugin/manifest.ts +330 -0
  33. package/src/plugin/quiz.ts +65 -0
  34. package/src/plugin/validation.ts +838 -0
  35. package/src/runtime/App.svelte +435 -0
  36. package/src/runtime/ErrorPage.svelte +14 -0
  37. package/src/runtime/LoadingSkeleton.svelte +26 -0
  38. package/src/runtime/Sidebar.svelte +76 -0
  39. package/src/runtime/access.ts +55 -0
  40. package/src/runtime/adapters/cmi5.ts +341 -0
  41. package/src/runtime/adapters/discovery.ts +38 -0
  42. package/src/runtime/adapters/index.ts +99 -0
  43. package/src/runtime/adapters/retry.ts +284 -0
  44. package/src/runtime/adapters/scorm12.ts +172 -0
  45. package/src/runtime/adapters/scorm2004.ts +162 -0
  46. package/src/runtime/adapters/web.ts +62 -0
  47. package/src/runtime/contexts.ts +76 -0
  48. package/src/runtime/duration.ts +29 -0
  49. package/src/runtime/hooks.svelte.ts +543 -0
  50. package/src/runtime/interaction-format.ts +132 -0
  51. package/src/runtime/interaction.ts +96 -0
  52. package/src/runtime/navigation.svelte.ts +117 -0
  53. package/src/runtime/persistence.ts +56 -0
  54. package/src/runtime/progress.svelte.ts +168 -0
  55. package/src/runtime/quiz-policy.ts +227 -0
  56. package/src/runtime/slugify.ts +17 -0
  57. package/src/runtime/types.ts +92 -0
  58. package/src/runtime/xapi/agent-rules.ts +93 -0
  59. package/src/runtime/xapi/client.ts +133 -0
  60. package/src/runtime/xapi/derive-actor.ts +90 -0
  61. package/src/runtime/xapi/publisher.ts +604 -0
  62. package/src/runtime/xapi/registry.ts +38 -0
  63. package/src/runtime/xapi/setup.ts +250 -0
  64. package/src/runtime/xapi/types.ts +106 -0
  65. package/src/runtime/xapi/uuid.ts +21 -0
  66. package/src/runtime/xapi/validation.ts +71 -0
  67. package/src/runtime/xapi/version.ts +23 -0
  68. package/src/virtual.d.ts +16 -0
  69. package/styles/base.css +194 -0
  70. package/styles/layout.css +408 -0
  71. package/styles/theme.css +36 -0
@@ -0,0 +1,363 @@
1
+ <script>
2
+ import { getContext, onMount } from 'svelte';
3
+ import { useQuestion } from '../runtime/hooks.svelte.js';
4
+ import { slugFromQuestion } from './util.js';
5
+
6
+ let {
7
+ id,
8
+ question,
9
+ options,
10
+ correct,
11
+ correctFeedback = '',
12
+ incorrectFeedback = '',
13
+ optionFeedback = [],
14
+ maxRetries = Infinity,
15
+ weight = 1,
16
+ } = $props();
17
+
18
+ const quiz = getContext('tessera-quiz');
19
+ const standalone = !quiz;
20
+
21
+ let selectedOption = $state(null);
22
+ let saRetryCount = $state(0);
23
+ let saCanRetry = $derived(saRetryCount < maxRetries);
24
+
25
+ // Unique IDs for accessibility
26
+ const groupId = `mc-${Math.random().toString(36).slice(2, 9)}`;
27
+ const defaultId = `mc-${slugFromQuestion(question)}`;
28
+
29
+ const handle = useQuestion({
30
+ id: id ?? defaultId,
31
+ weight,
32
+ response: () => ({
33
+ type: 'choice',
34
+ response: selectedOption !== null ? [String(selectedOption)] : [],
35
+ correct: [String(correct)],
36
+ }),
37
+ reset: () => { selectedOption = null; },
38
+ });
39
+
40
+ const myIndex = $derived(handle.quizIndex ?? -1);
41
+
42
+ onMount(() => {
43
+ if (!standalone) quiz.setRender(myIndex, renderQuestion);
44
+ });
45
+
46
+ function handleSelect(optIndex) {
47
+ if (standalone) {
48
+ if (handle.submitted) return;
49
+ selectedOption = optIndex;
50
+ handle.submit();
51
+ } else {
52
+ if (quizLocked) return;
53
+ selectedOption = optIndex;
54
+ quiz.setAnswer(myIndex, optIndex);
55
+ }
56
+ }
57
+
58
+ function handleRetry() {
59
+ saRetryCount++;
60
+ selectedOption = null;
61
+ handle.reset();
62
+ }
63
+
64
+ function isCorrectOption(optIndex) {
65
+ return optIndex === correct;
66
+ }
67
+
68
+ // Quiz-mode helpers
69
+ function getOptionClass(optIndex) {
70
+ if (standalone) {
71
+ if (!handle.submitted) return '';
72
+ if (isCorrectOption(optIndex)) return 'correct';
73
+ if (optIndex === selectedOption && !isCorrectOption(optIndex)) return 'incorrect';
74
+ return '';
75
+ }
76
+ if (!quiz.feedbackVisible(myIndex)) return '';
77
+ const answer = quiz.getAnswer(myIndex);
78
+ if (isCorrectOption(optIndex)) return 'correct';
79
+ if (optIndex === answer && !isCorrectOption(optIndex)) return 'incorrect';
80
+ return '';
81
+ }
82
+
83
+ let isLocked = $derived(standalone ? false : quiz.isLockedCorrect(myIndex));
84
+ let quizLocked = $derived(standalone ? handle.submitted : quiz.isAnswerLocked(myIndex));
85
+ </script>
86
+
87
+ {#if standalone}
88
+ <div class="tessera-mc" role="radiogroup" aria-labelledby="{groupId}-label">
89
+ <p class="tessera-mc-question" id="{groupId}-label">{question}</p>
90
+
91
+ <div class="tessera-mc-options">
92
+ {#each options as option, i}
93
+ {@const optionId = `${groupId}-opt-${i}`}
94
+ {@const isSelected = selectedOption === i}
95
+ {@const stateClass = getOptionClass(i)}
96
+ <label
97
+ class="tessera-mc-option {stateClass}"
98
+ class:selected={isSelected}
99
+ for={optionId}
100
+ >
101
+ <input
102
+ type="radio"
103
+ id={optionId}
104
+ name={groupId}
105
+ value={i}
106
+ checked={isSelected}
107
+ disabled={handle.submitted}
108
+ onchange={() => handleSelect(i)}
109
+ />
110
+ <span class="tessera-mc-radio-custom"></span>
111
+ <span class="tessera-mc-option-text">{option}</span>
112
+
113
+ {#if handle.submitted}
114
+ {#if stateClass === 'correct' && (correctFeedback || optionFeedback[i])}
115
+ <span class="tessera-mc-feedback correct">{optionFeedback[i] || correctFeedback}</span>
116
+ {:else if stateClass === 'incorrect' && (incorrectFeedback || optionFeedback[i])}
117
+ <span class="tessera-mc-feedback incorrect">{optionFeedback[i] || incorrectFeedback}</span>
118
+ {:else if optionFeedback[i]}
119
+ <span class="tessera-mc-feedback">{optionFeedback[i]}</span>
120
+ {/if}
121
+ {/if}
122
+ </label>
123
+ {/each}
124
+ </div>
125
+
126
+ {#if handle.submitted}
127
+ {#if selectedOption === correct && correctFeedback && !optionFeedback[selectedOption]}
128
+ <div class="tessera-mc-overall-feedback correct">{correctFeedback}</div>
129
+ {:else if selectedOption !== correct && incorrectFeedback && !optionFeedback[selectedOption]}
130
+ <div class="tessera-mc-overall-feedback incorrect">{incorrectFeedback}</div>
131
+ {/if}
132
+ {#if saCanRetry}
133
+ <button class="tessera-standalone-retry" onclick={handleRetry}>Try again</button>
134
+ {/if}
135
+ {/if}
136
+ </div>
137
+ {/if}
138
+
139
+ {#snippet renderQuestion()}
140
+ <div class="tessera-mc" role="radiogroup" aria-labelledby="{groupId}-label">
141
+ {#if isLocked}
142
+ <div class="tessera-quiz-locked-banner">
143
+ <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16" aria-hidden="true">
144
+ <path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
145
+ </svg>
146
+ You already got this one right — click Next to continue.
147
+ </div>
148
+ {/if}
149
+ <p class="tessera-mc-question" id="{groupId}-label">{question}</p>
150
+
151
+ <div class="tessera-mc-options">
152
+ {#each options as option, i}
153
+ {@const optionId = `${groupId}-opt-${i}`}
154
+ {@const isSelected = (quizLocked ? quiz.getAnswer(myIndex) : selectedOption) === i}
155
+ {@const stateClass = getOptionClass(i)}
156
+ <label
157
+ class="tessera-mc-option {stateClass}"
158
+ class:selected={isSelected}
159
+ for={optionId}
160
+ >
161
+ <input
162
+ type="radio"
163
+ id={optionId}
164
+ name={groupId}
165
+ value={i}
166
+ checked={isSelected}
167
+ disabled={quizLocked}
168
+ onchange={() => handleSelect(i)}
169
+ />
170
+ <span class="tessera-mc-radio-custom"></span>
171
+ <span class="tessera-mc-option-text">{option}</span>
172
+
173
+ {#if quiz.feedbackVisible(myIndex)}
174
+ {#if stateClass === 'correct' && (correctFeedback || optionFeedback[i])}
175
+ <span class="tessera-mc-feedback correct">{optionFeedback[i] || correctFeedback}</span>
176
+ {:else if stateClass === 'incorrect' && (incorrectFeedback || optionFeedback[i])}
177
+ <span class="tessera-mc-feedback incorrect">{optionFeedback[i] || incorrectFeedback}</span>
178
+ {:else if optionFeedback[i]}
179
+ <span class="tessera-mc-feedback">{optionFeedback[i]}</span>
180
+ {/if}
181
+ {/if}
182
+ </label>
183
+ {/each}
184
+ </div>
185
+
186
+ {#if quiz.feedbackVisible(myIndex)}
187
+ {@const answer = quiz.getAnswer(myIndex)}
188
+ {#if answer === correct && correctFeedback && !optionFeedback[answer]}
189
+ <div class="tessera-mc-overall-feedback correct">{correctFeedback}</div>
190
+ {:else if answer !== correct && incorrectFeedback && !optionFeedback[answer]}
191
+ <div class="tessera-mc-overall-feedback incorrect">{incorrectFeedback}</div>
192
+ {/if}
193
+ {/if}
194
+ </div>
195
+ {/snippet}
196
+
197
+ <style>
198
+ .tessera-mc {
199
+ padding: var(--tessera-spacing-md) 0;
200
+ }
201
+
202
+ .tessera-mc-question {
203
+ font-size: 1.125rem;
204
+ font-weight: 600;
205
+ margin-bottom: var(--tessera-spacing-lg);
206
+ color: var(--tessera-text);
207
+ }
208
+
209
+ .tessera-mc-options {
210
+ display: flex;
211
+ flex-direction: column;
212
+ gap: var(--tessera-spacing-sm);
213
+ }
214
+
215
+ .tessera-mc-option {
216
+ display: flex;
217
+ align-items: flex-start;
218
+ gap: var(--tessera-spacing-md);
219
+ padding: var(--tessera-spacing-md);
220
+ border: 2px solid var(--tessera-border);
221
+ border-radius: 8px;
222
+ cursor: pointer;
223
+ transition: border-color 0.2s, background 0.2s;
224
+ flex-wrap: wrap;
225
+ min-height: 44px;
226
+ }
227
+
228
+ .tessera-mc-option:hover:not(:has(input:disabled)) {
229
+ border-color: var(--tessera-primary);
230
+ background: var(--tessera-bg-secondary);
231
+ }
232
+
233
+ .tessera-mc-option.selected {
234
+ border-color: var(--tessera-primary);
235
+ background: var(--tessera-primary-light);
236
+ }
237
+
238
+ .tessera-mc-option.correct {
239
+ border-color: var(--tessera-success);
240
+ background: color-mix(in srgb, var(--tessera-success) 8%, transparent);
241
+ }
242
+
243
+ .tessera-mc-option.incorrect {
244
+ border-color: var(--tessera-error);
245
+ background: color-mix(in srgb, var(--tessera-error) 8%, transparent);
246
+ }
247
+
248
+ .tessera-mc-option input[type="radio"] {
249
+ position: absolute;
250
+ opacity: 0;
251
+ width: 0;
252
+ height: 0;
253
+ }
254
+
255
+ .tessera-mc-radio-custom {
256
+ flex-shrink: 0;
257
+ width: 20px;
258
+ height: 20px;
259
+ border: 2px solid var(--tessera-border);
260
+ border-radius: 50%;
261
+ margin-top: 2px;
262
+ transition: border-color 0.2s, background 0.2s;
263
+ position: relative;
264
+ }
265
+
266
+ .tessera-mc-option.selected .tessera-mc-radio-custom {
267
+ border-color: var(--tessera-primary);
268
+ }
269
+
270
+ .tessera-mc-option.selected .tessera-mc-radio-custom::after {
271
+ content: '';
272
+ position: absolute;
273
+ top: 3px;
274
+ left: 3px;
275
+ width: 10px;
276
+ height: 10px;
277
+ border-radius: 50%;
278
+ background: var(--tessera-primary);
279
+ }
280
+
281
+ .tessera-mc-option.correct .tessera-mc-radio-custom {
282
+ border-color: var(--tessera-success);
283
+ }
284
+
285
+ .tessera-mc-option.correct .tessera-mc-radio-custom::after {
286
+ background: var(--tessera-success);
287
+ }
288
+
289
+ .tessera-mc-option.incorrect .tessera-mc-radio-custom {
290
+ border-color: var(--tessera-error);
291
+ }
292
+
293
+ .tessera-mc-option.incorrect .tessera-mc-radio-custom::after {
294
+ background: var(--tessera-error);
295
+ }
296
+
297
+ .tessera-mc-option-text {
298
+ flex: 1;
299
+ line-height: 1.5;
300
+ color: var(--tessera-text);
301
+ }
302
+
303
+ .tessera-mc-feedback {
304
+ width: 100%;
305
+ font-size: 0.875rem;
306
+ padding: var(--tessera-spacing-sm) 0 0 calc(20px + var(--tessera-spacing-md));
307
+ line-height: 1.4;
308
+ }
309
+
310
+ .tessera-mc-feedback.correct {
311
+ color: var(--tessera-success);
312
+ }
313
+
314
+ .tessera-mc-feedback.incorrect {
315
+ color: var(--tessera-error);
316
+ }
317
+
318
+ .tessera-mc-overall-feedback {
319
+ margin-top: var(--tessera-spacing-md);
320
+ padding: var(--tessera-spacing-md);
321
+ border-radius: 6px;
322
+ font-size: 0.9375rem;
323
+ }
324
+
325
+ .tessera-mc-overall-feedback.correct {
326
+ background: color-mix(in srgb, var(--tessera-success) 8%, transparent);
327
+ color: var(--tessera-success);
328
+ }
329
+
330
+ .tessera-mc-overall-feedback.incorrect {
331
+ background: color-mix(in srgb, var(--tessera-error) 8%, transparent);
332
+ color: var(--tessera-error);
333
+ }
334
+
335
+ .tessera-standalone-retry {
336
+ display: inline-block;
337
+ margin-top: var(--tessera-spacing-md);
338
+ padding: 0;
339
+ font-size: 0.875rem;
340
+ font-weight: 600;
341
+ color: var(--tessera-primary);
342
+ background: none;
343
+ border: none;
344
+ cursor: pointer;
345
+ text-decoration: underline;
346
+ text-underline-offset: 2px;
347
+ }
348
+
349
+ .tessera-standalone-retry:hover {
350
+ color: var(--tessera-primary-dark);
351
+ }
352
+
353
+ .tessera-mc-option:has(input:focus-visible) {
354
+ outline: 2px solid var(--tessera-primary);
355
+ outline-offset: 2px;
356
+ }
357
+
358
+ @media (max-width: 640px) {
359
+ .tessera-mc-option {
360
+ padding: var(--tessera-spacing-md);
361
+ }
362
+ }
363
+ </style>