@lessonkit/react 1.0.1 → 1.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/dist/index.cjs CHANGED
@@ -30,16 +30,24 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.tsx
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ AssessmentSequence: () => AssessmentSequence,
33
34
  BLOCK_CATALOG: () => BLOCK_CATALOG,
35
+ BLOCK_CATALOG_V2: () => BLOCK_CATALOG_V2,
34
36
  Course: () => Course,
37
+ DragAndDrop: () => DragAndDrop,
38
+ DragTheWords: () => DragTheWords,
39
+ FillInTheBlanks: () => FillInTheBlanks,
35
40
  KnowledgeCheck: () => KnowledgeCheck,
36
41
  Lesson: () => Lesson,
37
42
  LessonkitProvider: () => LessonkitProvider,
43
+ MarkTheWords: () => MarkTheWords,
38
44
  ProgressTracker: () => ProgressTracker,
39
45
  Quiz: () => Quiz,
40
46
  Reflection: () => Reflection,
41
47
  Scenario: () => Scenario,
42
48
  ThemeProvider: () => ThemeProvider,
49
+ TrueFalse: () => TrueFalse,
50
+ blockCatalogV2Version: () => blockCatalogV2Version,
43
51
  blockCatalogVersion: () => blockCatalogVersion,
44
52
  buildBlockCatalog: () => buildBlockCatalog,
45
53
  buildTelemetryEvent: () => import_core10.buildTelemetryEvent,
@@ -50,7 +58,9 @@ __export(index_exports, {
50
58
  defineLifecyclePlugin: () => import_core10.defineLifecyclePlugin,
51
59
  defineTelemetryPlugin: () => import_core10.defineTelemetryPlugin,
52
60
  getBlockCatalogEntry: () => getBlockCatalogEntry,
61
+ resetAssessmentWarningsForTests: () => resetAssessmentWarningsForTests,
53
62
  resetQuizWarningsForTests: () => resetQuizWarningsForTests,
63
+ useAssessmentState: () => useAssessmentState,
54
64
  useCompletion: () => useCompletion,
55
65
  useLessonkit: () => useLessonkit,
56
66
  useProgress: () => useProgress,
@@ -61,9 +71,32 @@ __export(index_exports, {
61
71
  module.exports = __toCommonJS(index_exports);
62
72
 
63
73
  // src/components.tsx
64
- var import_react5 = require("react");
74
+ var import_react6 = require("react");
65
75
  var import_accessibility = require("@lessonkit/accessibility");
66
76
 
77
+ // src/assessment/scoring.ts
78
+ function resolvePassingThreshold(passingScore, maxScore) {
79
+ return passingScore ?? maxScore;
80
+ }
81
+ function meetsPassingThreshold(score, maxScore, passingScore) {
82
+ const threshold = resolvePassingThreshold(passingScore, maxScore);
83
+ return score >= threshold;
84
+ }
85
+ function scoreFromCustom(custom, fallbackCorrect, fallbackMax = 1, passingScore) {
86
+ const maxScore = custom?.maxScore ?? fallbackMax;
87
+ if (custom?.passed !== void 0) {
88
+ const score2 = custom.passed ? custom.score ?? maxScore : custom.score ?? 0;
89
+ return { score: score2, maxScore, passed: custom.passed };
90
+ }
91
+ if (custom?.maxScore != null && custom.maxScore > 0 && custom.score != null) {
92
+ const passed2 = meetsPassingThreshold(custom.score, custom.maxScore, passingScore);
93
+ return { score: custom.score, maxScore: custom.maxScore, passed: passed2 };
94
+ }
95
+ const score = fallbackCorrect ? maxScore : 0;
96
+ const passed = meetsPassingThreshold(score, maxScore, passingScore);
97
+ return { score, maxScore, passed };
98
+ }
99
+
67
100
  // src/context.tsx
68
101
  var import_react2 = require("react");
69
102
 
@@ -171,7 +204,40 @@ var import_core5 = require("@lessonkit/core");
171
204
 
172
205
  // src/runtime/courseStartedPipeline.ts
173
206
  var import_xapi3 = require("@lessonkit/xapi");
174
- function emitCourseStartedNonTrackingPipeline(opts) {
207
+ function isDevEnvironment3() {
208
+ const g = globalThis;
209
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
210
+ }
211
+ function warnExtraSinkFailure(sinkId, err) {
212
+ if (isDevEnvironment3()) {
213
+ console.warn(
214
+ `[lessonkit] course_started extra sink "${sinkId}" failed:`,
215
+ err instanceof Error ? err.message : err
216
+ );
217
+ }
218
+ }
219
+ async function emitExtraSinks(sinks, event, emitCtx) {
220
+ await Promise.all(
221
+ sinks.map(async (sink) => {
222
+ let result;
223
+ try {
224
+ result = sink.emit(event, emitCtx);
225
+ } catch (err) {
226
+ warnExtraSinkFailure(sink.id, err);
227
+ throw err;
228
+ }
229
+ if (result != null && typeof result.then === "function") {
230
+ try {
231
+ await result;
232
+ } catch (err) {
233
+ warnExtraSinkFailure(sink.id, err);
234
+ throw err;
235
+ }
236
+ }
237
+ })
238
+ );
239
+ }
240
+ async function emitCourseStartedNonTrackingPipeline(opts) {
175
241
  let xapiStatementSent = false;
176
242
  if (!opts.skipXapi && opts.xapi) {
177
243
  const statement = (0, import_xapi3.telemetryEventToXAPIStatement)(opts.event);
@@ -186,9 +252,7 @@ function emitCourseStartedNonTrackingPipeline(opts) {
186
252
  sessionId: opts.event.sessionId,
187
253
  attemptId: opts.event.attemptId
188
254
  };
189
- for (const sink of opts.extraSinks ?? []) {
190
- sink.emit(opts.event, emitCtx);
191
- }
255
+ await emitExtraSinks(opts.extraSinks ?? [], opts.event, emitCtx);
192
256
  return { xapiStatementSent };
193
257
  }
194
258
 
@@ -238,8 +302,12 @@ async function disposeTrackingClient(client) {
238
302
  }
239
303
 
240
304
  // src/provider/useLessonkitProviderRuntime.ts
241
- var useIsoLayoutEffect = typeof window !== "undefined" ? import_react.useLayoutEffect : import_react.useEffect;
305
+ var useIsoLayoutEffect = (
306
+ /* v8 ignore next -- SSR uses useEffect when window is unavailable */
307
+ typeof window !== "undefined" ? import_react.useLayoutEffect : import_react.useEffect
308
+ );
242
309
  var defaultStorage = (0, import_core3.createSessionStoragePort)();
310
+ var courseStartedTrackingFlightKey = null;
243
311
  function isTrackingActive(tracking) {
244
312
  return tracking?.enabled !== false;
245
313
  }
@@ -262,15 +330,41 @@ function buildCourseStartedEvent(opts) {
262
330
  });
263
331
  return opts.pluginHost ? opts.pluginHost.runTelemetry(built, pluginCtx) : built;
264
332
  }
265
- function emitCourseStartedPipelineOnly(opts) {
333
+ async function emitCourseStartedToTracking(tracking, storage, sessionId, courseId, event, shouldCommit) {
334
+ const flightKey = `${sessionId}:${courseId}`;
335
+ if ((0, import_core5.hasCourseStartedEmittedToTracking)(storage, sessionId, courseId)) {
336
+ return true;
337
+ }
338
+ if (courseStartedTrackingFlightKey === flightKey) {
339
+ return false;
340
+ }
341
+ courseStartedTrackingFlightKey = flightKey;
342
+ try {
343
+ if (shouldCommit && !shouldCommit()) return false;
344
+ tracking.track(event);
345
+ await tracking.flush?.();
346
+ if (shouldCommit && !shouldCommit()) return false;
347
+ (0, import_core5.markCourseStartedEmittedToTracking)(storage, sessionId, courseId);
348
+ return true;
349
+ } catch {
350
+ return false;
351
+ } finally {
352
+ if (courseStartedTrackingFlightKey === flightKey) {
353
+ courseStartedTrackingFlightKey = null;
354
+ }
355
+ }
356
+ }
357
+ async function emitCourseStartedPipelineOnly(opts) {
266
358
  try {
267
- const { xapiStatementSent } = emitCourseStartedNonTrackingPipeline({
359
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
360
+ const { xapiStatementSent } = await emitCourseStartedNonTrackingPipeline({
268
361
  event: opts.event,
269
362
  xapi: opts.xapi,
270
363
  lxpackBridge: opts.lxpackBridge,
271
364
  extraSinks: opts.extraSinks,
272
365
  skipXapi: opts.skipXapi
273
366
  });
367
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
274
368
  (0, import_core5.markCourseStarted)(opts.storage, opts.sessionId, opts.courseId);
275
369
  (0, import_core5.markCourseStartedPipelineDelivered)(opts.storage, opts.sessionId, opts.courseId);
276
370
  if (xapiStatementSent) {
@@ -281,47 +375,41 @@ function emitCourseStartedPipelineOnly(opts) {
281
375
  return "failed";
282
376
  }
283
377
  }
284
- function emitCourseStarted(opts) {
378
+ async function emitCourseStarted(opts) {
285
379
  const event = buildCourseStartedEvent(opts);
286
380
  if (event === null) return "filtered";
287
- const trackingAlreadyEmitted = (0, import_core5.hasCourseStartedEmittedToTracking)(
381
+ const tracked = await emitCourseStartedToTracking(
382
+ opts.tracking,
288
383
  opts.storage,
289
384
  opts.sessionId,
290
- opts.courseId
385
+ opts.courseId,
386
+ event,
387
+ opts.shouldCommit
291
388
  );
292
- if (!trackingAlreadyEmitted) {
293
- try {
294
- opts.tracking.track(event);
295
- (0, import_core5.markCourseStartedEmittedToTracking)(opts.storage, opts.sessionId, opts.courseId);
296
- } catch {
297
- return "failed";
298
- }
299
- }
389
+ if (!tracked) return "failed";
300
390
  return emitCourseStartedPipelineOnly({
301
391
  ...opts,
302
392
  event,
303
393
  skipXapi: opts.skipXapi,
304
- onXapiStatementSent: opts.onXapiStatementSent
394
+ onXapiStatementSent: opts.onXapiStatementSent,
395
+ shouldCommit: opts.shouldCommit
305
396
  });
306
397
  }
307
- function emitCourseStartedToTrackingOnly(opts) {
398
+ async function emitCourseStartedToTrackingOnly(opts) {
308
399
  const event = buildCourseStartedEvent(opts);
309
400
  if (event === null) return "filtered";
310
- const trackingAlreadyEmitted = (0, import_core5.hasCourseStartedEmittedToTracking)(
401
+ const tracked = await emitCourseStartedToTracking(
402
+ opts.tracking,
311
403
  opts.storage,
312
404
  opts.sessionId,
313
- opts.courseId
405
+ opts.courseId,
406
+ event,
407
+ opts.shouldCommit
314
408
  );
315
- if (!trackingAlreadyEmitted) {
316
- try {
317
- opts.tracking.track(event);
318
- (0, import_core5.markCourseStartedEmittedToTracking)(opts.storage, opts.sessionId, opts.courseId);
319
- } catch {
320
- return "failed";
321
- }
322
- }
409
+ if (!tracked) return "failed";
323
410
  try {
324
- emitCourseStartedNonTrackingPipeline({
411
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
412
+ await emitCourseStartedNonTrackingPipeline({
325
413
  event,
326
414
  xapi: null,
327
415
  lxpackBridge: opts.lxpackBridge,
@@ -334,7 +422,7 @@ function emitCourseStartedToTrackingOnly(opts) {
334
422
  return "failed";
335
423
  }
336
424
  }
337
- function emitPendingCourseStarted(opts) {
425
+ async function emitPendingCourseStarted(opts) {
338
426
  const trackingEmitted = (0, import_core5.hasCourseStartedEmittedToTracking)(
339
427
  opts.storage,
340
428
  opts.sessionId,
@@ -357,6 +445,9 @@ function emitPendingCourseStarted(opts) {
357
445
  opts.sessionId,
358
446
  opts.courseId
359
447
  );
448
+ if (sessionStarted && trackingEmitted && pipelineDelivered) {
449
+ return "emitted";
450
+ }
360
451
  if (sessionStarted && trackingEmitted && !pipelineDelivered) {
361
452
  const event = buildCourseStartedEvent(opts);
362
453
  if (event === null) return "filtered";
@@ -408,6 +499,7 @@ function useLessonkitProviderRuntime(config) {
408
499
  pluginHostRef.current = pluginHost;
409
500
  const progressRef = (0, import_react.useRef)((0, import_core4.createProgressController)());
410
501
  const courseStartedEmittedToSinkRef = (0, import_react.useRef)(false);
502
+ const courseStartedEmitGenerationRef = (0, import_react.useRef)(0);
411
503
  const prevCourseIdForProgressRef = (0, import_react.useRef)(normalizedCourseId);
412
504
  const pendingCourseIdResetRef = (0, import_react.useRef)(false);
413
505
  const prevUseV2RuntimeRef = (0, import_react.useRef)(useV2Runtime);
@@ -427,6 +519,7 @@ function useLessonkitProviderRuntime(config) {
427
519
  }
428
520
  pendingCourseIdResetRef.current = true;
429
521
  courseStartedEmittedToSinkRef.current = false;
522
+ courseStartedEmitGenerationRef.current += 1;
430
523
  } else if (useV2Runtime && !headlessRef.current) {
431
524
  headlessRef.current = (0, import_core8.createLessonkitRuntime)({
432
525
  courseId: normalizedCourseId,
@@ -444,6 +537,7 @@ function useLessonkitProviderRuntime(config) {
444
537
  }
445
538
  pendingCourseIdResetRef.current = true;
446
539
  courseStartedEmittedToSinkRef.current = false;
540
+ courseStartedEmitGenerationRef.current += 1;
447
541
  }
448
542
  if (useV2Runtime && headlessRef.current) {
449
543
  progressRef.current = headlessRef.current.progress;
@@ -557,7 +651,10 @@ function useLessonkitProviderRuntime(config) {
557
651
  const baseSink = normalizedConfig.tracking?.sink;
558
652
  const userBatchSink = normalizedConfig.tracking?.batchSink;
559
653
  assertTrackingSinkConfig(normalizedConfig.tracking);
560
- const sink = pluginHostRef.current && baseSink ? pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink : baseSink;
654
+ const sink = pluginHostRef.current && baseSink ? (
655
+ /* v8 ignore next -- composeTrackingSink may return null; fall back to base sink */
656
+ pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink
657
+ ) : baseSink;
561
658
  const batchSink = pluginHostRef.current && userBatchSink ? async (events) => {
562
659
  const host = pluginHostRef.current;
563
660
  const ctx = buildCurrentPluginCtx();
@@ -581,30 +678,39 @@ function useLessonkitProviderRuntime(config) {
581
678
  const sessionId = sessionIdRef.current;
582
679
  const cid = courseIdRef.current;
583
680
  const trackingActive = isTrackingActive(normalizedConfig.tracking);
681
+ const courseStartedFullySettled = (0, import_core5.hasCourseStartedEmittedToTracking)(defaultStorage, sessionId, cid) && (0, import_core5.hasCourseStarted)(defaultStorage, sessionId, cid) && (0, import_core5.hasCourseStartedPipelineDelivered)(defaultStorage, sessionId, cid);
584
682
  if (!trackingActive) {
585
683
  courseStartedEmittedToSinkRef.current = false;
586
- } else if (!courseStartedEmittedToSinkRef.current) {
587
- const result = emitPendingCourseStarted({
588
- pluginHost: pluginHostRef.current,
589
- tracking: next,
590
- xapi: xapiRef.current,
591
- storage: defaultStorage,
592
- sessionId,
593
- courseId: cid,
594
- attemptId: attemptIdRef.current,
595
- user: userRef.current,
596
- lxpackBridge: lxpackBridgeModeRef.current,
597
- extraSinks: extraSinksRef.current,
598
- skipXapi: xapiCourseStartedSentOnClientRef.current,
599
- onXapiStatementSent: () => {
600
- xapiCourseStartedSentOnClientRef.current = true;
601
- }
602
- });
603
- courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
604
- } else if (trackingActive) {
684
+ } else if (courseStartedFullySettled) {
605
685
  courseStartedEmittedToSinkRef.current = true;
686
+ } else if (!courseStartedEmittedToSinkRef.current) {
687
+ const generation = ++courseStartedEmitGenerationRef.current;
688
+ const shouldCommit = () => generation === courseStartedEmitGenerationRef.current;
689
+ void (async () => {
690
+ if (generation !== courseStartedEmitGenerationRef.current) return;
691
+ const result = await emitPendingCourseStarted({
692
+ pluginHost: pluginHostRef.current,
693
+ tracking: next,
694
+ xapi: xapiRef.current,
695
+ storage: defaultStorage,
696
+ sessionId,
697
+ courseId: cid,
698
+ attemptId: attemptIdRef.current,
699
+ user: userRef.current,
700
+ lxpackBridge: lxpackBridgeModeRef.current,
701
+ extraSinks: extraSinksRef.current,
702
+ skipXapi: xapiCourseStartedSentOnClientRef.current,
703
+ onXapiStatementSent: () => {
704
+ xapiCourseStartedSentOnClientRef.current = true;
705
+ },
706
+ shouldCommit
707
+ });
708
+ if (generation !== courseStartedEmitGenerationRef.current) return;
709
+ courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
710
+ })();
606
711
  }
607
712
  return () => {
713
+ courseStartedEmitGenerationRef.current += 1;
608
714
  if (prev !== trackingRef.current) {
609
715
  void disposeTrackingClient(prev);
610
716
  }
@@ -681,7 +787,7 @@ function useLessonkitProviderRuntime(config) {
681
787
  } catch {
682
788
  }
683
789
  if (!courseStartedEmittedToSinkRef.current) {
684
- const result = emitPendingCourseStarted({
790
+ const result = await emitPendingCourseStarted({
685
791
  pluginHost: pluginHostRef.current,
686
792
  tracking: trackingRef.current,
687
793
  xapi: xapiRef.current,
@@ -707,7 +813,10 @@ function useLessonkitProviderRuntime(config) {
707
813
  [track]
708
814
  );
709
815
  const completeLesson = (0, import_react.useCallback)(
710
- (lessonId) => {
816
+ (lessonId, opts) => {
817
+ if (opts?.courseId !== void 0 && opts.courseId !== courseIdRef.current) {
818
+ return;
819
+ }
711
820
  if (useV2Runtime && headlessRef.current) {
712
821
  headlessRef.current.completeLesson(lessonId, emitLifecycleEvent);
713
822
  syncProgress();
@@ -882,9 +991,29 @@ function LessonkitProvider(props) {
882
991
  }
883
992
 
884
993
  // src/hooks.ts
994
+ var import_react4 = require("react");
995
+
996
+ // src/assessment/useAssessmentState.ts
885
997
  var import_react3 = require("react");
998
+ function useAssessmentState(enclosingLessonId) {
999
+ const { track } = useLessonkit();
1000
+ const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
1001
+ return (0, import_react3.useMemo)(
1002
+ () => ({
1003
+ answer: (data) => {
1004
+ track("assessment_answered", data, trackOpts);
1005
+ },
1006
+ complete: (data) => {
1007
+ track("assessment_completed", data, trackOpts);
1008
+ }
1009
+ }),
1010
+ [track, enclosingLessonId]
1011
+ );
1012
+ }
1013
+
1014
+ // src/hooks.ts
886
1015
  function useLessonkit() {
887
- const ctx = (0, import_react3.useContext)(LessonkitContext);
1016
+ const ctx = (0, import_react4.useContext)(LessonkitContext);
888
1017
  if (!ctx) throw new Error("LessonKit: missing LessonkitProvider");
889
1018
  return ctx;
890
1019
  }
@@ -894,16 +1023,16 @@ function useProgress() {
894
1023
  }
895
1024
  function useTracking() {
896
1025
  const { track } = useLessonkit();
897
- return (0, import_react3.useMemo)(() => ({ track }), [track]);
1026
+ return (0, import_react4.useMemo)(() => ({ track }), [track]);
898
1027
  }
899
1028
  function useCompletion() {
900
1029
  const { completeLesson, completeCourse } = useLessonkit();
901
- return (0, import_react3.useMemo)(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
1030
+ return (0, import_react4.useMemo)(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
902
1031
  }
903
1032
  function useQuizState(enclosingLessonId) {
904
1033
  const { track } = useLessonkit();
905
1034
  const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
906
- return (0, import_react3.useMemo)(
1035
+ return (0, import_react4.useMemo)(
907
1036
  () => ({
908
1037
  answer: (opts) => {
909
1038
  track("quiz_answered", opts, trackOpts);
@@ -917,15 +1046,15 @@ function useQuizState(enclosingLessonId) {
917
1046
  }
918
1047
 
919
1048
  // src/lessonContext.tsx
920
- var import_react4 = require("react");
921
- var LessonContext = (0, import_react4.createContext)(void 0);
1049
+ var import_react5 = require("react");
1050
+ var LessonContext = (0, import_react5.createContext)(void 0);
922
1051
  function useEnclosingLessonId() {
923
- return (0, import_react4.useContext)(LessonContext);
1052
+ return (0, import_react5.useContext)(LessonContext);
924
1053
  }
925
1054
 
926
1055
  // src/runtime/validateComponentId.ts
927
1056
  var import_core9 = require("@lessonkit/core");
928
- function isDevEnvironment3() {
1057
+ function isDevEnvironment4() {
929
1058
  const g = globalThis;
930
1059
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
931
1060
  }
@@ -941,7 +1070,7 @@ function normalizeComponentId(id, path) {
941
1070
  var mountCounts = /* @__PURE__ */ new Map();
942
1071
  var warnedConcurrentLessons = false;
943
1072
  function registerLessonMount(lessonId) {
944
- if (isDevEnvironment3() && mountCounts.size > 0 && !mountCounts.has(lessonId) && !warnedConcurrentLessons) {
1073
+ if (isDevEnvironment4() && mountCounts.size > 0 && !mountCounts.has(lessonId) && !warnedConcurrentLessons) {
945
1074
  warnedConcurrentLessons = true;
946
1075
  console.warn(
947
1076
  "[lessonkit] Multiple <Lesson> components are mounted; only one should be active at a time. Set autoCompleteOnUnmount={false} on routed lessons or unmount the previous lesson before showing the next."
@@ -968,8 +1097,8 @@ function resetQuizWarningsForTests() {
968
1097
  warnedQuizOutsideLesson = false;
969
1098
  }
970
1099
  function Course(props) {
971
- const courseId = (0, import_react5.useMemo)(() => normalizeComponentId(props.courseId, "courseId"), [props.courseId]);
972
- const providerConfig = (0, import_react5.useMemo)(
1100
+ const courseId = (0, import_react6.useMemo)(() => normalizeComponentId(props.courseId, "courseId"), [props.courseId]);
1101
+ const providerConfig = (0, import_react6.useMemo)(
973
1102
  () => ({ ...props.config, courseId }),
974
1103
  [props.config, courseId]
975
1104
  );
@@ -979,14 +1108,23 @@ function Course(props) {
979
1108
  ] }) });
980
1109
  }
981
1110
  function Lesson(props) {
982
- const lessonId = (0, import_react5.useMemo)(() => normalizeComponentId(props.lessonId, "lessonId"), [props.lessonId]);
1111
+ const lessonId = (0, import_react6.useMemo)(() => normalizeComponentId(props.lessonId, "lessonId"), [props.lessonId]);
983
1112
  const autoComplete = props.autoCompleteOnUnmount !== false;
984
1113
  const { setActiveLesson, config } = useLessonkit();
985
1114
  const { completeLesson } = useCompletion();
986
- const lessonMountGenerationRef = (0, import_react5.useRef)(0);
987
- (0, import_react5.useEffect)(() => {
1115
+ const lessonMountGenerationRef = (0, import_react6.useRef)(0);
1116
+ const liveCourseIdRef = (0, import_react6.useRef)(config.courseId);
1117
+ liveCourseIdRef.current = config.courseId;
1118
+ (0, import_react6.useEffect)(() => {
988
1119
  const unregister = registerLessonMount(lessonId);
989
1120
  const generation = ++lessonMountGenerationRef.current;
1121
+ const mountedCourseId = config.courseId;
1122
+ let effectSurvivedTick = false;
1123
+ queueMicrotask(() => {
1124
+ queueMicrotask(() => {
1125
+ effectSurvivedTick = true;
1126
+ });
1127
+ });
990
1128
  setActiveLesson(lessonId);
991
1129
  return () => {
992
1130
  unregister();
@@ -995,8 +1133,10 @@ function Lesson(props) {
995
1133
  }
996
1134
  if (!autoComplete) return;
997
1135
  queueMicrotask(() => {
1136
+ if (!effectSurvivedTick) return;
998
1137
  if (lessonMountGenerationRef.current !== generation) return;
999
- completeLesson(lessonId);
1138
+ if (liveCourseIdRef.current !== mountedCourseId) return;
1139
+ completeLesson(lessonId, { courseId: mountedCourseId });
1000
1140
  });
1001
1141
  };
1002
1142
  }, [lessonId, config.courseId, setActiveLesson, completeLesson, autoComplete]);
@@ -1006,20 +1146,20 @@ function Lesson(props) {
1006
1146
  ] }) });
1007
1147
  }
1008
1148
  function Scenario(props) {
1009
- const blockId = (0, import_react5.useMemo)(
1149
+ const blockId = (0, import_react6.useMemo)(
1010
1150
  () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
1011
1151
  [props.blockId]
1012
1152
  );
1013
1153
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { "aria-label": "Scenario", "data-lk-block-id": blockId, children: props.children });
1014
1154
  }
1015
1155
  function Reflection(props) {
1016
- const blockId = (0, import_react5.useMemo)(
1156
+ const blockId = (0, import_react6.useMemo)(
1017
1157
  () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
1018
1158
  [props.blockId]
1019
1159
  );
1020
- const promptId = (0, import_react5.useId)();
1021
- const hintId = (0, import_react5.useId)();
1022
- const [internalValue, setInternalValue] = (0, import_react5.useState)("");
1160
+ const promptId = (0, import_react6.useId)();
1161
+ const hintId = (0, import_react6.useId)();
1162
+ const [internalValue, setInternalValue] = (0, import_react6.useState)("");
1023
1163
  const isControlled = props.value !== void 0;
1024
1164
  const value = isControlled ? props.value : internalValue;
1025
1165
  const handleChange = (event) => {
@@ -1055,11 +1195,10 @@ function KnowledgeCheck(props) {
1055
1195
  );
1056
1196
  }
1057
1197
  function Quiz(props) {
1058
- const checkId = (0, import_react5.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1059
1198
  const enclosingLessonId = useEnclosingLessonId();
1060
1199
  const missingLesson = enclosingLessonId === void 0;
1061
- (0, import_react5.useEffect)(() => {
1062
- if (!missingLesson || isDevEnvironment3()) return;
1200
+ (0, import_react6.useEffect)(() => {
1201
+ if (!missingLesson || isDevEnvironment4()) return;
1063
1202
  if (!warnedQuizOutsideLesson) {
1064
1203
  warnedQuizOutsideLesson = true;
1065
1204
  console.error(
@@ -1067,18 +1206,26 @@ function Quiz(props) {
1067
1206
  );
1068
1207
  }
1069
1208
  }, [missingLesson]);
1070
- if (missingLesson && isDevEnvironment3()) {
1209
+ if (missingLesson && isDevEnvironment4()) {
1071
1210
  throw new Error("[lessonkit] <Quiz> must be wrapped in <Lesson>");
1072
1211
  }
1212
+ if (missingLesson) {
1213
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { role: "alert", "aria-label": "Quiz configuration error", "data-lk-check-id": props.checkId, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { children: "Quiz must be placed inside a Lesson." }) });
1214
+ }
1215
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(QuizInner, { ...props, enclosingLessonId });
1216
+ }
1217
+ function QuizInner(props) {
1218
+ const { enclosingLessonId } = props;
1219
+ const checkId = (0, import_react6.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1073
1220
  const quiz = useQuizState(enclosingLessonId);
1074
1221
  const { plugins, config, session } = useLessonkit();
1075
- const [selected, setSelected] = (0, import_react5.useState)(null);
1076
- const [selectionCorrect, setSelectionCorrect] = (0, import_react5.useState)(null);
1077
- const [quizPassed, setQuizPassed] = (0, import_react5.useState)(false);
1078
- const completedRef = (0, import_react5.useRef)(false);
1079
- const questionId = (0, import_react5.useId)();
1222
+ const [selected, setSelected] = (0, import_react6.useState)(null);
1223
+ const [selectionCorrect, setSelectionCorrect] = (0, import_react6.useState)(null);
1224
+ const [quizPassed, setQuizPassed] = (0, import_react6.useState)(false);
1225
+ const completedRef = (0, import_react6.useRef)(false);
1226
+ const questionId = (0, import_react6.useId)();
1080
1227
  const choicesKey = props.choices.join("\0");
1081
- (0, import_react5.useEffect)(() => {
1228
+ (0, import_react6.useEffect)(() => {
1082
1229
  completedRef.current = false;
1083
1230
  setQuizPassed(false);
1084
1231
  setSelected(null);
@@ -1087,14 +1234,11 @@ function Quiz(props) {
1087
1234
  const isChoiceCorrect = (choice, custom) => {
1088
1235
  if (!custom) return choice === props.answer;
1089
1236
  if (custom.passed !== void 0) return custom.passed;
1090
- if (custom.maxScore != null && custom.maxScore > 0) {
1091
- return custom.score / custom.maxScore >= 1;
1237
+ if (custom.maxScore != null && custom.maxScore > 0 && custom.score != null) {
1238
+ return meetsPassingThreshold(custom.score, custom.maxScore, props.passingScore);
1092
1239
  }
1093
1240
  return choice === props.answer;
1094
1241
  };
1095
- if (missingLesson) {
1096
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { role: "alert", "aria-label": "Quiz configuration error", "data-lk-check-id": checkId, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { children: "Quiz must be placed inside a Lesson." }) });
1097
- }
1098
1242
  const passed = quizPassed;
1099
1243
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", "data-lk-check-id": checkId, children: [
1100
1244
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: questionId, children: props.question }),
@@ -1141,7 +1285,7 @@ function Quiz(props) {
1141
1285
  const maxScore = custom?.maxScore ?? 1;
1142
1286
  quiz.complete({
1143
1287
  checkId,
1144
- score: custom?.score ?? 1,
1288
+ score: custom?.score ?? maxScore,
1145
1289
  maxScore,
1146
1290
  passingScore: props.passingScore ?? maxScore
1147
1291
  });
@@ -1184,11 +1328,864 @@ function ProgressTracker(props) {
1184
1328
  ] }) });
1185
1329
  }
1186
1330
 
1331
+ // src/blocks/TrueFalse.tsx
1332
+ var import_react9 = __toESM(require("react"), 1);
1333
+
1334
+ // src/assessment/AssessmentLessonGuard.tsx
1335
+ var import_react7 = require("react");
1336
+ var import_jsx_runtime3 = require("react/jsx-runtime");
1337
+ var warnedAssessmentOutsideLesson = false;
1338
+ function resetAssessmentWarningsForTests() {
1339
+ warnedAssessmentOutsideLesson = false;
1340
+ }
1341
+ function AssessmentLessonGuard(props) {
1342
+ const enclosingLessonId = useEnclosingLessonId();
1343
+ const missingLesson = enclosingLessonId === void 0;
1344
+ (0, import_react7.useEffect)(() => {
1345
+ if (!missingLesson || isDevEnvironment4()) return;
1346
+ if (!warnedAssessmentOutsideLesson) {
1347
+ warnedAssessmentOutsideLesson = true;
1348
+ console.error(
1349
+ `[lessonkit] <${props.blockLabel}> must be wrapped in <Lesson>; assessment telemetry will not be emitted.`
1350
+ );
1351
+ }
1352
+ }, [missingLesson, props.blockLabel]);
1353
+ if (missingLesson && isDevEnvironment4()) {
1354
+ throw new Error(`[lessonkit] <${props.blockLabel}> must be wrapped in <Lesson>`);
1355
+ }
1356
+ if (missingLesson) {
1357
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("section", { role: "alert", "aria-label": `${props.blockLabel} configuration error`, "data-lk-check-id": props.checkId, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("p", { children: [
1358
+ props.blockLabel,
1359
+ " must be placed inside a Lesson."
1360
+ ] }) });
1361
+ }
1362
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_jsx_runtime3.Fragment, { children: props.children(enclosingLessonId) });
1363
+ }
1364
+
1365
+ // src/assessment/AssessmentSequenceContext.tsx
1366
+ var import_react8 = __toESM(require("react"), 1);
1367
+ var import_jsx_runtime4 = require("react/jsx-runtime");
1368
+ var AssessmentSequenceContext = (0, import_react8.createContext)(null);
1369
+ function AssessmentSequenceProvider({ children }) {
1370
+ const registryRef = (0, import_react8.useRef)(/* @__PURE__ */ new Map());
1371
+ const register = (0, import_react8.useCallback)((checkId, handle) => {
1372
+ registryRef.current.set(checkId, handle);
1373
+ return () => {
1374
+ registryRef.current.delete(checkId);
1375
+ };
1376
+ }, []);
1377
+ const value = (0, import_react8.useMemo)(
1378
+ () => ({
1379
+ register,
1380
+ getHandles: () => registryRef.current
1381
+ }),
1382
+ [register]
1383
+ );
1384
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(AssessmentSequenceContext.Provider, { value, children });
1385
+ }
1386
+ function useAssessmentSequenceRegistry() {
1387
+ return (0, import_react8.useContext)(AssessmentSequenceContext);
1388
+ }
1389
+ function useRegisterAssessmentHandle(checkId, handle) {
1390
+ const ctx = useAssessmentSequenceRegistry();
1391
+ import_react8.default.useEffect(() => {
1392
+ if (!ctx || !handle) return;
1393
+ return ctx.register(checkId, handle);
1394
+ }, [ctx, checkId, handle]);
1395
+ }
1396
+
1397
+ // src/blocks/TrueFalse.tsx
1398
+ var import_jsx_runtime5 = require("react/jsx-runtime");
1399
+ var INTERACTION = "trueFalse";
1400
+ function TrueFalseInner(props, ref) {
1401
+ const { enclosingLessonId } = props;
1402
+ const checkId = (0, import_react9.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1403
+ const assessment = useAssessmentState(enclosingLessonId);
1404
+ const { plugins, config, session } = useLessonkit();
1405
+ const [selected, setSelected] = (0, import_react9.useState)(null);
1406
+ const [selectionCorrect, setSelectionCorrect] = (0, import_react9.useState)(null);
1407
+ const [showSolutions, setShowSolutions] = (0, import_react9.useState)(false);
1408
+ const [passed, setPassed] = (0, import_react9.useState)(false);
1409
+ const completedRef = (0, import_react9.useRef)(false);
1410
+ const questionId = import_react9.default.useId();
1411
+ const reset = () => {
1412
+ completedRef.current = false;
1413
+ setPassed(false);
1414
+ setSelected(null);
1415
+ setSelectionCorrect(null);
1416
+ setShowSolutions(false);
1417
+ };
1418
+ (0, import_react9.useEffect)(() => {
1419
+ reset();
1420
+ }, [checkId, props.answer, props.question, config.courseId, enclosingLessonId]);
1421
+ const handle = (0, import_react9.useMemo)(() => {
1422
+ const maxScore = 1;
1423
+ const score = passed ? maxScore : selected === null ? 0 : selected === props.answer ? maxScore : 0;
1424
+ return {
1425
+ getScore: () => score,
1426
+ getMaxScore: () => maxScore,
1427
+ getAnswerGiven: () => selected !== null,
1428
+ resetTask: reset,
1429
+ showSolutions: () => setShowSolutions(true),
1430
+ getXAPIData: () => ({
1431
+ checkId,
1432
+ interactionType: INTERACTION,
1433
+ response: selected ?? void 0,
1434
+ correct: selected === props.answer,
1435
+ score,
1436
+ maxScore
1437
+ })
1438
+ };
1439
+ }, [checkId, passed, props.answer, selected]);
1440
+ (0, import_react9.useImperativeHandle)(ref, () => handle, [handle]);
1441
+ useRegisterAssessmentHandle(checkId, handle);
1442
+ const submit = (value) => {
1443
+ if (passed && !props.enableRetry) return;
1444
+ setSelected(value);
1445
+ const pluginCtx = buildPluginContext({
1446
+ courseId: config.courseId,
1447
+ sessionId: session.sessionId,
1448
+ attemptId: session.attemptId,
1449
+ user: session.user
1450
+ });
1451
+ const custom = plugins?.scoreAssessment(
1452
+ { checkId, lessonId: enclosingLessonId, response: value },
1453
+ pluginCtx
1454
+ ) ?? null;
1455
+ const correct = value === props.answer;
1456
+ const scored = scoreFromCustom(custom, correct, 1, props.passingScore);
1457
+ setSelectionCorrect(scored.passed);
1458
+ assessment.answer({
1459
+ checkId,
1460
+ interactionType: INTERACTION,
1461
+ question: props.question,
1462
+ response: value,
1463
+ correct: scored.passed
1464
+ });
1465
+ if (scored.passed && !completedRef.current) {
1466
+ completedRef.current = true;
1467
+ setPassed(true);
1468
+ assessment.complete({
1469
+ checkId,
1470
+ interactionType: INTERACTION,
1471
+ score: scored.score,
1472
+ maxScore: scored.maxScore,
1473
+ passingScore: props.passingScore ?? scored.maxScore
1474
+ });
1475
+ }
1476
+ };
1477
+ const reveal = showSolutions || passed && props.enableSolutionsButton;
1478
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("section", { "aria-label": "True or False", "data-lk-check-id": checkId, children: [
1479
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { id: questionId, children: props.question }),
1480
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("fieldset", { "aria-labelledby": questionId, children: [
1481
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("legend", { className: "lk-visually-hidden", children: "True or False" }),
1482
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("label", { style: { display: "block", marginRight: "1rem" }, children: [
1483
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
1484
+ "input",
1485
+ {
1486
+ type: "radio",
1487
+ name: `${questionId}-tf`,
1488
+ checked: selected === true,
1489
+ disabled: passed && !props.enableRetry,
1490
+ onChange: () => submit(true)
1491
+ }
1492
+ ),
1493
+ "True"
1494
+ ] }),
1495
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("label", { style: { display: "block" }, children: [
1496
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
1497
+ "input",
1498
+ {
1499
+ type: "radio",
1500
+ name: `${questionId}-tf`,
1501
+ checked: selected === false,
1502
+ disabled: passed && !props.enableRetry,
1503
+ onChange: () => submit(false)
1504
+ }
1505
+ ),
1506
+ "False"
1507
+ ] })
1508
+ ] }),
1509
+ reveal ? /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("p", { children: [
1510
+ "Correct answer: ",
1511
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("strong", { children: props.answer ? "True" : "False" })
1512
+ ] }) : null,
1513
+ selected !== null && selectionCorrect !== null ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { role: "status", "aria-live": "polite", children: selectionCorrect ? "Correct" : "Try again" }) : null,
1514
+ props.enableRetry && passed ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("button", { type: "button", onClick: reset, children: "Try again" }) : null,
1515
+ props.enableSolutionsButton && !reveal ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("button", { type: "button", onClick: () => setShowSolutions(true), children: "Show solution" }) : null
1516
+ ] });
1517
+ }
1518
+ var TrueFalseInnerForwarded = (0, import_react9.forwardRef)(TrueFalseInner);
1519
+ var TrueFalse = (0, import_react9.forwardRef)(function TrueFalse2(props, ref) {
1520
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(AssessmentLessonGuard, { blockLabel: "TrueFalse", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(TrueFalseInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
1521
+ });
1522
+
1523
+ // src/blocks/MarkTheWords.tsx
1524
+ var import_react10 = __toESM(require("react"), 1);
1525
+ var import_jsx_runtime6 = require("react/jsx-runtime");
1526
+ var INTERACTION2 = "markTheWords";
1527
+ function tokenize(text) {
1528
+ return text.split(/(\s+)/).filter((t) => t.length > 0);
1529
+ }
1530
+ function MarkTheWordsInner(props, ref) {
1531
+ const checkId = (0, import_react10.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1532
+ const assessment = useAssessmentState(props.enclosingLessonId);
1533
+ const tokens = (0, import_react10.useMemo)(() => tokenize(props.text), [props.text]);
1534
+ const correctSet = (0, import_react10.useMemo)(
1535
+ () => new Set(props.correctWords.map((w) => w.toLowerCase())),
1536
+ [props.correctWords]
1537
+ );
1538
+ const [marked, setMarked] = (0, import_react10.useState)(() => /* @__PURE__ */ new Set());
1539
+ const [passed, setPassed] = (0, import_react10.useState)(false);
1540
+ const [showSolutions, setShowSolutions] = (0, import_react10.useState)(false);
1541
+ const completedRef = (0, import_react10.useRef)(false);
1542
+ const reset = () => {
1543
+ completedRef.current = false;
1544
+ setPassed(false);
1545
+ setMarked(/* @__PURE__ */ new Set());
1546
+ setShowSolutions(false);
1547
+ };
1548
+ (0, import_react10.useEffect)(() => {
1549
+ reset();
1550
+ }, [checkId, props.text, props.correctWords.join("\0")]);
1551
+ const selectableIndices = (0, import_react10.useMemo)(() => {
1552
+ const indices = [];
1553
+ tokens.forEach((t, i) => {
1554
+ if (!/^\s+$/.test(t) && correctSet.has(t.toLowerCase())) indices.push(i);
1555
+ });
1556
+ return indices;
1557
+ }, [tokens, correctSet]);
1558
+ const hasTargets = selectableIndices.length > 0;
1559
+ const allMarked = hasTargets && selectableIndices.every((i) => marked.has(i));
1560
+ const maxScore = selectableIndices.length;
1561
+ const score = allMarked ? maxScore : marked.size;
1562
+ const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
1563
+ const handle = (0, import_react10.useMemo)(() => {
1564
+ const handleMax = maxScore || 1;
1565
+ return {
1566
+ getScore: () => score,
1567
+ getMaxScore: () => handleMax,
1568
+ getAnswerGiven: () => marked.size > 0,
1569
+ resetTask: reset,
1570
+ showSolutions: () => setShowSolutions(true),
1571
+ getXAPIData: () => ({
1572
+ checkId,
1573
+ interactionType: INTERACTION2,
1574
+ response: [...marked].map((i) => tokens[i]),
1575
+ correct: passedThreshold,
1576
+ score,
1577
+ maxScore: handleMax
1578
+ })
1579
+ };
1580
+ }, [checkId, marked, maxScore, passedThreshold, score, tokens]);
1581
+ (0, import_react10.useImperativeHandle)(ref, () => handle, [handle]);
1582
+ useRegisterAssessmentHandle(checkId, handle);
1583
+ const toggle = (index) => {
1584
+ if (passed && !props.enableRetry) return;
1585
+ setMarked((prev) => {
1586
+ const next = new Set(prev);
1587
+ if (next.has(index)) next.delete(index);
1588
+ else next.add(index);
1589
+ return next;
1590
+ });
1591
+ };
1592
+ (0, import_react10.useEffect)(() => {
1593
+ if (!hasTargets) {
1594
+ if (isDevEnvironment4()) {
1595
+ console.warn(
1596
+ "[lessonkit] MarkTheWords: no tokens match correctWords",
1597
+ props.correctWords
1598
+ );
1599
+ }
1600
+ return;
1601
+ }
1602
+ if (!passedThreshold || completedRef.current) return;
1603
+ completedRef.current = true;
1604
+ setPassed(true);
1605
+ assessment.answer({
1606
+ checkId,
1607
+ interactionType: INTERACTION2,
1608
+ question: props.text,
1609
+ response: [...marked].map((i) => tokens[i]),
1610
+ correct: true
1611
+ });
1612
+ assessment.complete({
1613
+ checkId,
1614
+ interactionType: INTERACTION2,
1615
+ score,
1616
+ maxScore,
1617
+ passingScore: props.passingScore ?? maxScore
1618
+ });
1619
+ }, [
1620
+ assessment,
1621
+ checkId,
1622
+ hasTargets,
1623
+ marked,
1624
+ maxScore,
1625
+ passedThreshold,
1626
+ props.passingScore,
1627
+ props.correctWords,
1628
+ props.text,
1629
+ score,
1630
+ tokens
1631
+ ]);
1632
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("section", { "aria-label": "Mark the Words", "data-lk-check-id": checkId, children: [
1633
+ !hasTargets ? /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("p", { role: "alert", children: [
1634
+ "No words in this sentence match ",
1635
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("code", { children: "correctWords" }),
1636
+ ". Check spelling and capitalization in the source text."
1637
+ ] }) : null,
1638
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("p", { id: `${checkId}-hint`, children: "Select the correct words in the sentence." }),
1639
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("p", { "aria-describedby": `${checkId}-hint`, children: tokens.map((token, i) => {
1640
+ const isWord = !/^\s+$/.test(token);
1641
+ const isTarget = isWord && correctSet.has(token.toLowerCase());
1642
+ if (!isTarget) return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_react10.default.Fragment, { children: token }, i);
1643
+ const selected = marked.has(i);
1644
+ const solution = showSolutions || passed && props.enableSolutionsButton;
1645
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
1646
+ "button",
1647
+ {
1648
+ type: "button",
1649
+ "data-testid": `mark-word-${i}`,
1650
+ "aria-pressed": selected,
1651
+ disabled: passed && !props.enableRetry,
1652
+ onClick: () => toggle(i),
1653
+ style: {
1654
+ margin: "0 0.1em",
1655
+ textDecoration: solution ? "underline" : void 0,
1656
+ fontWeight: selected || solution ? "bold" : void 0
1657
+ },
1658
+ children: token
1659
+ },
1660
+ i
1661
+ );
1662
+ }) }),
1663
+ allMarked ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("p", { role: "status", "aria-live": "polite", children: "Correct" }) : null,
1664
+ props.enableRetry && passed ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("button", { type: "button", onClick: reset, children: "Try again" }) : null,
1665
+ props.enableSolutionsButton && !showSolutions ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("button", { type: "button", onClick: () => setShowSolutions(true), children: "Show solution" }) : null
1666
+ ] });
1667
+ }
1668
+ var MarkTheWordsInnerForwarded = (0, import_react10.forwardRef)(MarkTheWordsInner);
1669
+ var MarkTheWords = (0, import_react10.forwardRef)(function MarkTheWords2(props, ref) {
1670
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(AssessmentLessonGuard, { blockLabel: "MarkTheWords", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(MarkTheWordsInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
1671
+ });
1672
+
1673
+ // src/blocks/FillInTheBlanks.tsx
1674
+ var import_react11 = __toESM(require("react"), 1);
1675
+ var import_jsx_runtime7 = require("react/jsx-runtime");
1676
+ var INTERACTION3 = "fillInBlanks";
1677
+ function parseTemplate(template) {
1678
+ const parts = [];
1679
+ const blanks = [];
1680
+ const re = /\*([^*]+)\*/g;
1681
+ let last = 0;
1682
+ let match;
1683
+ let n = 0;
1684
+ while ((match = re.exec(template)) !== null) {
1685
+ parts.push(template.slice(last, match.index));
1686
+ const id = `blank-${n++}`;
1687
+ blanks.push({ id, answer: match[1].trim() });
1688
+ parts.push(id);
1689
+ last = match.index + match[0].length;
1690
+ }
1691
+ parts.push(template.slice(last));
1692
+ return { parts, blanks };
1693
+ }
1694
+ function FillInTheBlanksInner(props, ref) {
1695
+ const checkId = (0, import_react11.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1696
+ const assessment = useAssessmentState(props.enclosingLessonId);
1697
+ const parsed = (0, import_react11.useMemo)(() => parseTemplate(props.template), [props.template]);
1698
+ const blanks = props.blanks ?? parsed.blanks;
1699
+ const [values, setValues] = (0, import_react11.useState)(
1700
+ () => Object.fromEntries(blanks.map((b) => [b.id, ""]))
1701
+ );
1702
+ const [passed, setPassed] = (0, import_react11.useState)(false);
1703
+ const [showSolutions, setShowSolutions] = (0, import_react11.useState)(false);
1704
+ const completedRef = (0, import_react11.useRef)(false);
1705
+ const answeredRef = (0, import_react11.useRef)(false);
1706
+ const reset = () => {
1707
+ completedRef.current = false;
1708
+ answeredRef.current = false;
1709
+ setPassed(false);
1710
+ setValues(Object.fromEntries(blanks.map((b) => [b.id, ""])));
1711
+ setShowSolutions(false);
1712
+ };
1713
+ (0, import_react11.useEffect)(() => {
1714
+ reset();
1715
+ }, [checkId, props.template, blanks.map((b) => b.answer).join("\0")]);
1716
+ const hasBlanks = blanks.length > 0;
1717
+ const allFilled = hasBlanks && blanks.every((b) => (values[b.id] ?? "").trim().length > 0);
1718
+ let score = 0;
1719
+ blanks.forEach((b) => {
1720
+ if ((values[b.id] ?? "").trim().toLowerCase() === b.answer.toLowerCase()) score += 1;
1721
+ });
1722
+ const maxScore = blanks.length;
1723
+ const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
1724
+ const handle = (0, import_react11.useMemo)(() => {
1725
+ const handleMax = maxScore || 1;
1726
+ return {
1727
+ getScore: () => score,
1728
+ getMaxScore: () => handleMax,
1729
+ getAnswerGiven: () => allFilled,
1730
+ resetTask: reset,
1731
+ showSolutions: () => setShowSolutions(true),
1732
+ getXAPIData: () => ({
1733
+ checkId,
1734
+ interactionType: INTERACTION3,
1735
+ response: values,
1736
+ correct: passedThreshold,
1737
+ score,
1738
+ maxScore: handleMax
1739
+ })
1740
+ };
1741
+ }, [allFilled, blanks.length, checkId, maxScore, passedThreshold, score, values]);
1742
+ (0, import_react11.useImperativeHandle)(ref, () => handle, [handle]);
1743
+ useRegisterAssessmentHandle(checkId, handle);
1744
+ const check = () => {
1745
+ if (!hasBlanks) {
1746
+ if (isDevEnvironment4()) {
1747
+ console.warn("[lessonkit] FillInTheBlanks has no blanks in template");
1748
+ }
1749
+ return;
1750
+ }
1751
+ if (!allFilled) return;
1752
+ if (!answeredRef.current) {
1753
+ answeredRef.current = true;
1754
+ assessment.answer({
1755
+ checkId,
1756
+ interactionType: INTERACTION3,
1757
+ question: props.template,
1758
+ response: values,
1759
+ correct: passedThreshold
1760
+ });
1761
+ }
1762
+ if (passedThreshold && !completedRef.current) {
1763
+ completedRef.current = true;
1764
+ setPassed(true);
1765
+ assessment.complete({
1766
+ checkId,
1767
+ interactionType: INTERACTION3,
1768
+ score,
1769
+ maxScore,
1770
+ passingScore: props.passingScore ?? maxScore
1771
+ });
1772
+ }
1773
+ };
1774
+ (0, import_react11.useEffect)(() => {
1775
+ if (!allFilled) answeredRef.current = false;
1776
+ }, [allFilled]);
1777
+ (0, import_react11.useEffect)(() => {
1778
+ if (props.autoCheck && allFilled) check();
1779
+ }, [allFilled, props.autoCheck, values, passedThreshold]);
1780
+ const reveal = showSolutions || passed && props.enableSolutionsButton;
1781
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("section", { "aria-label": "Fill in the Blanks", "data-lk-check-id": checkId, children: [
1782
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { children: parsed.parts.map((part, i) => {
1783
+ const blank = blanks.find((b) => b.id === part);
1784
+ if (!blank) return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_react11.default.Fragment, { children: part }, i);
1785
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("label", { style: { margin: "0 0.25em" }, children: [
1786
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: "lk-visually-hidden", children: blank.answer }),
1787
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1788
+ "input",
1789
+ {
1790
+ type: "text",
1791
+ "data-testid": `blank-${blank.id}`,
1792
+ "aria-label": `Blank ${blank.id}`,
1793
+ value: reveal ? blank.answer : values[blank.id] ?? "",
1794
+ readOnly: reveal,
1795
+ disabled: passed && !props.enableRetry,
1796
+ onChange: (e) => setValues((v) => ({ ...v, [blank.id]: e.target.value })),
1797
+ onBlur: () => props.autoCheck && check(),
1798
+ size: Math.max(8, blank.answer.length + 2)
1799
+ }
1800
+ )
1801
+ ] }, blank.id);
1802
+ }) }),
1803
+ !props.autoCheck ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { type: "button", "data-testid": "check-blanks", disabled: !allFilled || passed, onClick: check, children: "Check" }) : null,
1804
+ !hasBlanks ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { role: "alert", children: "This activity has no blanks. Add text wrapped in asterisks, e.g. The *answer* here." }) : null,
1805
+ allFilled ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { role: "status", "aria-live": "polite", children: passed || passedThreshold ? "Correct" : "Try again" }) : null,
1806
+ props.enableRetry && passed ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { type: "button", onClick: reset, children: "Try again" }) : null,
1807
+ props.enableSolutionsButton && !reveal ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { type: "button", onClick: () => setShowSolutions(true), children: "Show solution" }) : null
1808
+ ] });
1809
+ }
1810
+ var FillInTheBlanksInnerForwarded = (0, import_react11.forwardRef)(FillInTheBlanksInner);
1811
+ var FillInTheBlanks = (0, import_react11.forwardRef)(
1812
+ function FillInTheBlanks2(props, ref) {
1813
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(AssessmentLessonGuard, { blockLabel: "FillInTheBlanks", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(FillInTheBlanksInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
1814
+ }
1815
+ );
1816
+
1817
+ // src/blocks/DragTheWords.tsx
1818
+ var import_react12 = __toESM(require("react"), 1);
1819
+ var import_jsx_runtime8 = require("react/jsx-runtime");
1820
+ var INTERACTION4 = "dragTheWords";
1821
+ function parseZones(template) {
1822
+ const parts = [];
1823
+ const answers = [];
1824
+ const re = /\*([^*]+)\*/g;
1825
+ let last = 0;
1826
+ let match;
1827
+ let n = 0;
1828
+ while ((match = re.exec(template)) !== null) {
1829
+ parts.push(template.slice(last, match.index));
1830
+ answers.push(match[1].trim());
1831
+ parts.push(`zone-${n++}`);
1832
+ last = match.index + match[0].length;
1833
+ }
1834
+ parts.push(template.slice(last));
1835
+ return { parts, answers };
1836
+ }
1837
+ function DragTheWordsInner(props, ref) {
1838
+ const checkId = (0, import_react12.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1839
+ const assessment = useAssessmentState(props.enclosingLessonId);
1840
+ const { parts, answers } = (0, import_react12.useMemo)(() => parseZones(props.template), [props.template]);
1841
+ const [zones, setZones] = (0, import_react12.useState)(
1842
+ () => Object.fromEntries(answers.map((_, i) => [`zone-${i}`, ""]))
1843
+ );
1844
+ const [pool, setPool] = (0, import_react12.useState)(() => [...props.words]);
1845
+ const [keyboardWord, setKeyboardWord] = (0, import_react12.useState)(null);
1846
+ const [passed, setPassed] = (0, import_react12.useState)(false);
1847
+ const completedRef = (0, import_react12.useRef)(false);
1848
+ const answeredRef = (0, import_react12.useRef)(false);
1849
+ const reset = () => {
1850
+ completedRef.current = false;
1851
+ answeredRef.current = false;
1852
+ setPassed(false);
1853
+ setZones(Object.fromEntries(answers.map((_, i) => [`zone-${i}`, ""])));
1854
+ setPool([...props.words]);
1855
+ setKeyboardWord(null);
1856
+ };
1857
+ (0, import_react12.useEffect)(() => {
1858
+ reset();
1859
+ }, [checkId, props.template, props.words.join("\0")]);
1860
+ const hasZones = answers.length > 0;
1861
+ const allFilled = hasZones && answers.every((_, i) => (zones[`zone-${i}`] ?? "").length > 0);
1862
+ let score = 0;
1863
+ answers.forEach((ans, i) => {
1864
+ if ((zones[`zone-${i}`] ?? "").trim().toLowerCase() === ans.toLowerCase()) score += 1;
1865
+ });
1866
+ const maxScore = answers.length;
1867
+ const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
1868
+ const handle = (0, import_react12.useMemo)(() => {
1869
+ const handleMax = maxScore || 1;
1870
+ return {
1871
+ getScore: () => score,
1872
+ getMaxScore: () => handleMax,
1873
+ getAnswerGiven: () => allFilled,
1874
+ resetTask: reset,
1875
+ showSolutions: () => {
1876
+ },
1877
+ getXAPIData: () => ({
1878
+ checkId,
1879
+ interactionType: INTERACTION4,
1880
+ response: zones,
1881
+ correct: passedThreshold,
1882
+ score,
1883
+ maxScore: handleMax
1884
+ })
1885
+ };
1886
+ }, [allFilled, answers.length, checkId, maxScore, passedThreshold, score, zones]);
1887
+ (0, import_react12.useImperativeHandle)(ref, () => handle, [handle]);
1888
+ useRegisterAssessmentHandle(checkId, handle);
1889
+ const placeInZone = (zoneId, word) => {
1890
+ if (passed && !props.enableRetry) return;
1891
+ const prev = zones[zoneId];
1892
+ setZones((z) => ({ ...z, [zoneId]: word }));
1893
+ setPool((p) => {
1894
+ const next = p.filter((w) => w !== word);
1895
+ if (prev) next.push(prev);
1896
+ return next;
1897
+ });
1898
+ setKeyboardWord(null);
1899
+ };
1900
+ const onDragStart = (word) => (e) => {
1901
+ e.dataTransfer.setData("text/plain", word);
1902
+ };
1903
+ const onDrop = (zoneId) => (e) => {
1904
+ e.preventDefault();
1905
+ const word = e.dataTransfer.getData("text/plain");
1906
+ if (word) placeInZone(zoneId, word);
1907
+ };
1908
+ const check = () => {
1909
+ if (!hasZones) {
1910
+ if (isDevEnvironment4()) {
1911
+ console.warn("[lessonkit] DragTheWords has no drop zones in template");
1912
+ }
1913
+ return;
1914
+ }
1915
+ if (!allFilled) return;
1916
+ if (!answeredRef.current) {
1917
+ answeredRef.current = true;
1918
+ assessment.answer({
1919
+ checkId,
1920
+ interactionType: INTERACTION4,
1921
+ question: props.template,
1922
+ response: zones,
1923
+ correct: passedThreshold
1924
+ });
1925
+ }
1926
+ if (passedThreshold && !completedRef.current) {
1927
+ completedRef.current = true;
1928
+ setPassed(true);
1929
+ assessment.complete({
1930
+ checkId,
1931
+ interactionType: INTERACTION4,
1932
+ score,
1933
+ maxScore,
1934
+ passingScore: props.passingScore ?? maxScore
1935
+ });
1936
+ }
1937
+ };
1938
+ (0, import_react12.useEffect)(() => {
1939
+ if (!allFilled) answeredRef.current = false;
1940
+ }, [allFilled]);
1941
+ (0, import_react12.useEffect)(() => {
1942
+ if (props.autoCheck && allFilled) check();
1943
+ }, [allFilled, props.autoCheck, zones, passedThreshold]);
1944
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("section", { "aria-label": "Drag the Words", "data-lk-check-id": checkId, children: [
1945
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("p", { children: "Drag words into the blanks (or select a word, then activate a blank)." }),
1946
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { role: "list", "aria-label": "Word bank", "data-testid": "word-bank", children: pool.map((word) => /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
1947
+ "button",
1948
+ {
1949
+ type: "button",
1950
+ draggable: true,
1951
+ "data-testid": `word-${word}`,
1952
+ "aria-pressed": keyboardWord === word,
1953
+ onDragStart: onDragStart(word),
1954
+ onClick: () => setKeyboardWord(keyboardWord === word ? null : word),
1955
+ style: { margin: "0.25rem" },
1956
+ children: word
1957
+ },
1958
+ word
1959
+ )) }),
1960
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("p", { children: parts.map((part, i) => {
1961
+ if (!part.startsWith("zone-")) return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react12.default.Fragment, { children: part }, i);
1962
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
1963
+ "span",
1964
+ {
1965
+ role: "button",
1966
+ tabIndex: 0,
1967
+ "data-testid": part,
1968
+ onDragOver: (e) => e.preventDefault(),
1969
+ onDrop: onDrop(part),
1970
+ onClick: () => keyboardWord && placeInZone(part, keyboardWord),
1971
+ onKeyDown: (e) => {
1972
+ if (e.key === "Enter" && keyboardWord) placeInZone(part, keyboardWord);
1973
+ },
1974
+ style: {
1975
+ display: "inline-block",
1976
+ minWidth: "6em",
1977
+ border: "1px dashed currentColor",
1978
+ padding: "0.2em 0.5em",
1979
+ margin: "0 0.2em"
1980
+ },
1981
+ children: zones[part] || "___"
1982
+ },
1983
+ part
1984
+ );
1985
+ }) }),
1986
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("button", { type: "button", "data-testid": "check-drag-words", disabled: !allFilled || passed, onClick: check, children: "Check" }),
1987
+ !hasZones ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("p", { role: "alert", children: "This activity has no drop zones. Wrap answers in asterisks in the template." }) : null,
1988
+ allFilled ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("p", { role: "status", "aria-live": "polite", children: passed || passedThreshold ? "Correct" : "Try again" }) : null
1989
+ ] });
1990
+ }
1991
+ var DragTheWordsInnerForwarded = (0, import_react12.forwardRef)(DragTheWordsInner);
1992
+ var DragTheWords = (0, import_react12.forwardRef)(function DragTheWords2(props, ref) {
1993
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(AssessmentLessonGuard, { blockLabel: "DragTheWords", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(DragTheWordsInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
1994
+ });
1995
+
1996
+ // src/blocks/DragAndDrop.tsx
1997
+ var import_react13 = require("react");
1998
+ var import_jsx_runtime9 = require("react/jsx-runtime");
1999
+ var INTERACTION5 = "dragAndDrop";
2000
+ function DragAndDropInner(props, ref) {
2001
+ const checkId = (0, import_react13.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
2002
+ const assessment = useAssessmentState(props.enclosingLessonId);
2003
+ const [assignments, setAssignments] = (0, import_react13.useState)(
2004
+ () => Object.fromEntries(props.targets.map((t) => [t.id, ""]))
2005
+ );
2006
+ const [pool, setPool] = (0, import_react13.useState)(() => props.items.map((i) => i.id));
2007
+ const [keyboardItem, setKeyboardItem] = (0, import_react13.useState)(null);
2008
+ const [passed, setPassed] = (0, import_react13.useState)(false);
2009
+ const completedRef = (0, import_react13.useRef)(false);
2010
+ const reset = () => {
2011
+ completedRef.current = false;
2012
+ setPassed(false);
2013
+ setAssignments(Object.fromEntries(props.targets.map((t) => [t.id, ""])));
2014
+ setPool(props.items.map((i) => i.id));
2015
+ setKeyboardItem(null);
2016
+ };
2017
+ (0, import_react13.useEffect)(() => {
2018
+ reset();
2019
+ }, [checkId, props.items.map((i) => i.id).join(","), props.targets.map((t) => t.id).join(",")]);
2020
+ const allFilled = props.targets.every((t) => (assignments[t.id] ?? "").length > 0);
2021
+ const allCorrect = props.targets.every((t) => assignments[t.id] === t.accepts);
2022
+ const handle = (0, import_react13.useMemo)(() => {
2023
+ const maxScore = props.targets.length || 1;
2024
+ let score = 0;
2025
+ props.targets.forEach((t) => {
2026
+ if (assignments[t.id] === t.accepts) score += 1;
2027
+ });
2028
+ return {
2029
+ getScore: () => score,
2030
+ getMaxScore: () => maxScore,
2031
+ getAnswerGiven: () => allFilled,
2032
+ resetTask: reset,
2033
+ showSolutions: () => {
2034
+ },
2035
+ getXAPIData: () => ({
2036
+ checkId,
2037
+ interactionType: INTERACTION5,
2038
+ response: assignments,
2039
+ correct: allCorrect,
2040
+ score,
2041
+ maxScore
2042
+ })
2043
+ };
2044
+ }, [allCorrect, allFilled, assignments, checkId, props.targets]);
2045
+ (0, import_react13.useImperativeHandle)(ref, () => handle, [handle]);
2046
+ useRegisterAssessmentHandle(checkId, handle);
2047
+ const place = (targetId, itemId) => {
2048
+ if (passed && !props.enableRetry) return;
2049
+ const prev = assignments[targetId];
2050
+ setAssignments((a) => ({ ...a, [targetId]: itemId }));
2051
+ setPool((p) => {
2052
+ const next = p.filter((id) => id !== itemId);
2053
+ if (prev) next.push(prev);
2054
+ return next;
2055
+ });
2056
+ setKeyboardItem(null);
2057
+ };
2058
+ const check = () => {
2059
+ if (!allFilled) return;
2060
+ assessment.answer({
2061
+ checkId,
2062
+ interactionType: INTERACTION5,
2063
+ response: assignments,
2064
+ correct: allCorrect
2065
+ });
2066
+ if (allCorrect && !completedRef.current) {
2067
+ completedRef.current = true;
2068
+ setPassed(true);
2069
+ assessment.complete({
2070
+ checkId,
2071
+ interactionType: INTERACTION5,
2072
+ score: props.targets.length,
2073
+ maxScore: props.targets.length,
2074
+ passingScore: props.passingScore ?? props.targets.length
2075
+ });
2076
+ }
2077
+ };
2078
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("section", { "aria-label": "Drag and Drop", "data-lk-check-id": checkId, children: [
2079
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { children: "Match each item to the correct target (drag or use keyboard: select item, then activate target)." }),
2080
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { role: "list", "aria-label": "Draggable items", children: pool.map((id) => {
2081
+ const item = props.items.find((i) => i.id === id);
2082
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
2083
+ "button",
2084
+ {
2085
+ type: "button",
2086
+ draggable: true,
2087
+ "data-testid": `drag-item-${id}`,
2088
+ "aria-pressed": keyboardItem === id,
2089
+ onDragStart: (e) => e.dataTransfer.setData("text/plain", id),
2090
+ onClick: () => setKeyboardItem(keyboardItem === id ? null : id),
2091
+ style: { margin: "0.25rem" },
2092
+ children: item.label
2093
+ },
2094
+ id
2095
+ );
2096
+ }) }),
2097
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("ul", { children: props.targets.map((target) => {
2098
+ const assigned = assignments[target.id];
2099
+ const label = assigned ? props.items.find((i) => i.id === assigned)?.label ?? assigned : "Drop here";
2100
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("li", { children: [
2101
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("strong", { children: target.label }),
2102
+ " ",
2103
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
2104
+ "span",
2105
+ {
2106
+ role: "button",
2107
+ tabIndex: 0,
2108
+ "data-testid": `drop-${target.id}`,
2109
+ onDragOver: (e) => e.preventDefault(),
2110
+ onDrop: (e) => {
2111
+ e.preventDefault();
2112
+ const id = e.dataTransfer.getData("text/plain");
2113
+ if (id) place(target.id, id);
2114
+ },
2115
+ onClick: () => keyboardItem && place(target.id, keyboardItem),
2116
+ onKeyDown: (e) => {
2117
+ if (e.key === "Enter" && keyboardItem) place(target.id, keyboardItem);
2118
+ },
2119
+ style: {
2120
+ display: "inline-block",
2121
+ minWidth: "8em",
2122
+ border: "1px dashed currentColor",
2123
+ padding: "0.25em"
2124
+ },
2125
+ children: label
2126
+ }
2127
+ )
2128
+ ] }, target.id);
2129
+ }) }),
2130
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("button", { type: "button", "data-testid": "check-drag-drop", disabled: !allFilled || passed, onClick: check, children: "Check" }),
2131
+ allFilled ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { role: "status", "aria-live": "polite", children: passed || allCorrect ? "Correct" : "Try again" }) : null
2132
+ ] });
2133
+ }
2134
+ var DragAndDropInnerForwarded = (0, import_react13.forwardRef)(DragAndDropInner);
2135
+ var DragAndDrop = (0, import_react13.forwardRef)(function DragAndDrop2(props, ref) {
2136
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(AssessmentLessonGuard, { blockLabel: "DragAndDrop", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(DragAndDropInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
2137
+ });
2138
+
2139
+ // src/blocks/AssessmentSequence.tsx
2140
+ var import_react14 = __toESM(require("react"), 1);
2141
+ var import_jsx_runtime10 = require("react/jsx-runtime");
2142
+ function AssessmentSequence(props) {
2143
+ const sequential = props.sequential !== false;
2144
+ const childArray = import_react14.default.Children.toArray(props.children).filter(import_react14.default.isValidElement);
2145
+ const [index, setIndex] = (0, import_react14.useState)(0);
2146
+ const current = childArray[index] ?? null;
2147
+ const goNext = (0, import_react14.useCallback)(() => {
2148
+ setIndex((i) => Math.min(i + 1, childArray.length - 1));
2149
+ }, [childArray.length]);
2150
+ const goPrev = (0, import_react14.useCallback)(() => {
2151
+ setIndex((i) => Math.max(i - 1, 0));
2152
+ }, []);
2153
+ const progress = (0, import_react14.useMemo)(
2154
+ () => ({ current: index + 1, total: childArray.length }),
2155
+ [index, childArray.length]
2156
+ );
2157
+ if (!sequential) {
2158
+ return /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(AssessmentSequenceProvider, { children: /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("section", { "aria-label": "Assessment sequence", children: props.children }) });
2159
+ }
2160
+ return /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(AssessmentSequenceProvider, { children: /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("section", { "aria-label": "Assessment sequence", "data-testid": "assessment-sequence", children: [
2161
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("p", { children: [
2162
+ "Question ",
2163
+ progress.current,
2164
+ " of ",
2165
+ progress.total
2166
+ ] }),
2167
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("div", { "data-testid": "assessment-sequence-step", children: current }),
2168
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("nav", { "aria-label": "Sequence navigation", children: [
2169
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("button", { type: "button", "data-testid": "sequence-prev", disabled: index === 0, onClick: goPrev, children: "Previous" }),
2170
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
2171
+ "button",
2172
+ {
2173
+ type: "button",
2174
+ "data-testid": "sequence-next",
2175
+ disabled: index >= childArray.length - 1,
2176
+ onClick: goNext,
2177
+ children: "Next"
2178
+ }
2179
+ )
2180
+ ] })
2181
+ ] }) });
2182
+ }
2183
+
1187
2184
  // src/index.tsx
1188
2185
  var import_core10 = require("@lessonkit/core");
1189
2186
 
1190
2187
  // src/theme/ThemeProvider.tsx
1191
- var import_react6 = __toESM(require("react"), 1);
2188
+ var import_react15 = __toESM(require("react"), 1);
1192
2189
  var import_themes = require("@lessonkit/themes");
1193
2190
 
1194
2191
  // src/theme/applyCssVariables.ts
@@ -1207,9 +2204,12 @@ function applyCssVariables(target, vars, previousKeys) {
1207
2204
  }
1208
2205
 
1209
2206
  // src/theme/ThemeProvider.tsx
1210
- var import_jsx_runtime3 = require("react/jsx-runtime");
1211
- var ThemeContext = (0, import_react6.createContext)(null);
1212
- var useIsoLayoutEffect2 = typeof window !== "undefined" ? import_react6.useLayoutEffect : import_react6.default.useEffect;
2207
+ var import_jsx_runtime11 = require("react/jsx-runtime");
2208
+ var ThemeContext = (0, import_react15.createContext)(null);
2209
+ var useIsoLayoutEffect2 = (
2210
+ /* v8 ignore next -- SSR uses useEffect when window is unavailable */
2211
+ typeof window !== "undefined" ? import_react15.useLayoutEffect : import_react15.default.useEffect
2212
+ );
1213
2213
  function getSystemMode() {
1214
2214
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
1215
2215
  return "light";
@@ -1227,7 +2227,7 @@ function ThemeProvider(props) {
1227
2227
  const preset = props.preset ?? "default";
1228
2228
  const mode = props.mode ?? "light";
1229
2229
  const targetKind = props.target ?? "document";
1230
- const [resolvedMode, setResolvedMode] = (0, import_react6.useState)(
2230
+ const [resolvedMode, setResolvedMode] = (0, import_react15.useState)(
1231
2231
  () => mode === "system" ? getSystemMode() : mode
1232
2232
  );
1233
2233
  useIsoLayoutEffect2(() => {
@@ -1243,20 +2243,20 @@ function ThemeProvider(props) {
1243
2243
  return () => mq.removeEventListener("change", onChange);
1244
2244
  }, [mode]);
1245
2245
  const dataTheme = mode === "system" ? resolvedMode : mode === "dark" ? "dark" : "light";
1246
- const effectiveTheme = (0, import_react6.useMemo)(() => {
2246
+ const effectiveTheme = (0, import_react15.useMemo)(() => {
1247
2247
  const modeBase = resolveModeBase(mode, dataTheme);
1248
2248
  const base = preset === "default" ? modeBase : preset === "brand" ? (0, import_themes.mergeThemes)(modeBase, import_themes.brandThemeOverrides) : (0, import_themes.mergeThemes)(modeBase, (0, import_themes.getPresetTheme)(preset));
1249
2249
  return (0, import_themes.mergeThemes)(base, props.theme ?? {});
1250
2250
  }, [preset, mode, dataTheme, props.theme]);
1251
- const hostRef = (0, import_react6.useRef)(null);
1252
- const appliedKeysRef = (0, import_react6.useRef)(/* @__PURE__ */ new Set());
2251
+ const hostRef = (0, import_react15.useRef)(null);
2252
+ const appliedKeysRef = (0, import_react15.useRef)(/* @__PURE__ */ new Set());
1253
2253
  useIsoLayoutEffect2(() => {
1254
2254
  if (targetKind === "document" && typeof document !== "undefined") {
1255
2255
  document.documentElement.setAttribute("data-lk-theme", dataTheme);
1256
2256
  return () => document.documentElement.removeAttribute("data-lk-theme");
1257
2257
  }
1258
2258
  }, [targetKind, dataTheme]);
1259
- const inject = (0, import_react6.useCallback)(() => {
2259
+ const inject = (0, import_react15.useCallback)(() => {
1260
2260
  const vars = (0, import_themes.themeToCssVariables)(effectiveTheme);
1261
2261
  const el = targetKind === "document" && typeof document !== "undefined" ? document.documentElement : hostRef.current;
1262
2262
  if (!el) return;
@@ -1273,7 +2273,7 @@ function ThemeProvider(props) {
1273
2273
  appliedKeysRef.current = /* @__PURE__ */ new Set();
1274
2274
  };
1275
2275
  }, [inject, targetKind]);
1276
- const value = (0, import_react6.useMemo)(
2276
+ const value = (0, import_react15.useMemo)(
1277
2277
  () => ({
1278
2278
  theme: effectiveTheme,
1279
2279
  preset,
@@ -1283,12 +2283,12 @@ function ThemeProvider(props) {
1283
2283
  [effectiveTheme, preset, mode, dataTheme]
1284
2284
  );
1285
2285
  if (targetKind === "document") {
1286
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ThemeContext.Provider, { value, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { "data-lk-theme": dataTheme, style: { display: "contents" }, children: props.children }) });
2286
+ return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(ThemeContext.Provider, { value, children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { "data-lk-theme": dataTheme, style: { display: "contents" }, children: props.children }) });
1287
2287
  }
1288
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ThemeContext.Provider, { value, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { ref: hostRef, "data-lk-theme": dataTheme, children: props.children }) });
2288
+ return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(ThemeContext.Provider, { value, children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { ref: hostRef, "data-lk-theme": dataTheme, children: props.children }) });
1289
2289
  }
1290
2290
  function useTheme() {
1291
- const ctx = (0, import_react6.useContext)(ThemeContext);
2291
+ const ctx = (0, import_react15.useContext)(ThemeContext);
1292
2292
  if (!ctx) {
1293
2293
  throw new Error("useTheme must be used within a ThemeProvider");
1294
2294
  }
@@ -1297,6 +2297,7 @@ function useTheme() {
1297
2297
 
1298
2298
  // src/blockCatalog.ts
1299
2299
  var blockCatalogVersion = 1;
2300
+ var blockCatalogV2Version = 2;
1300
2301
  var BLOCK_CATALOG = [
1301
2302
  {
1302
2303
  type: "Course",
@@ -1483,8 +2484,163 @@ var BLOCK_CATALOG = [
1483
2484
  }
1484
2485
  }
1485
2486
  ];
1486
- function buildBlockCatalog() {
1487
- return BLOCK_CATALOG.map((entry) => ({
2487
+ var assessmentBehaviourProps = [
2488
+ { name: "enableRetry", type: "boolean", required: false, description: "Allow retry after completion." },
2489
+ { name: "enableSolutionsButton", type: "boolean", required: false, description: "Show solution control." },
2490
+ { name: "autoCheck", type: "boolean", required: false, description: "Check answers automatically when possible." },
2491
+ { name: "passingScore", type: "number", required: false, description: "Minimum score to pass." }
2492
+ ];
2493
+ var v2AssessmentEntries = [
2494
+ {
2495
+ type: "TrueFalse",
2496
+ category: "assessment",
2497
+ assessmentContract: true,
2498
+ h5pMachineName: "H5P.TrueFalse",
2499
+ h5pAlias: "True/False",
2500
+ description: "Binary true/false question with assessment contract.",
2501
+ props: [
2502
+ { name: "checkId", type: "CheckId", required: true, description: "Stable check id." },
2503
+ { name: "question", type: "string", required: true, description: "Question text." },
2504
+ { name: "answer", type: "boolean", required: true, description: "Correct answer." },
2505
+ ...assessmentBehaviourProps
2506
+ ],
2507
+ requiredIds: ["checkId"],
2508
+ parentConstraints: ["Lesson", "AssessmentSequence"],
2509
+ a11y: {
2510
+ element: "section",
2511
+ ariaLabel: "True or False",
2512
+ keyboard: "Radio group with True/False options.",
2513
+ liveRegions: "role='status' for feedback.",
2514
+ notes: "H5P True/False equivalent."
2515
+ },
2516
+ theming: { surface: "global-inherit", dataAttributes: ["data-lk-check-id"], stylingNotes: "Uses data-lk-check-id." },
2517
+ telemetry: { emits: ["assessment_answered", "assessment_completed"], requiresActiveLesson: true }
2518
+ },
2519
+ {
2520
+ type: "FillInTheBlanks",
2521
+ category: "assessment",
2522
+ assessmentContract: true,
2523
+ h5pMachineName: "H5P.Blanks",
2524
+ h5pAlias: "Fill in the Blanks",
2525
+ description: "Fill-in-the-blank text with *answer* markers in template.",
2526
+ props: [
2527
+ { name: "checkId", type: "CheckId", required: true, description: "Stable check id." },
2528
+ { name: "template", type: "string", required: true, description: "Text with *blank* markers." },
2529
+ { name: "blanks", type: "FillInBlankSpec[]", required: false, description: "Explicit blank specs." },
2530
+ ...assessmentBehaviourProps
2531
+ ],
2532
+ requiredIds: ["checkId"],
2533
+ parentConstraints: ["Lesson", "AssessmentSequence"],
2534
+ a11y: {
2535
+ element: "section",
2536
+ ariaLabel: "Fill in the Blanks",
2537
+ keyboard: "Tab between text inputs.",
2538
+ notes: "H5P Fill in the Blanks equivalent."
2539
+ },
2540
+ theming: { surface: "global-inherit", dataAttributes: ["data-lk-check-id"], stylingNotes: "Uses data-lk-check-id." },
2541
+ telemetry: { emits: ["assessment_answered", "assessment_completed"], requiresActiveLesson: true }
2542
+ },
2543
+ {
2544
+ type: "DragAndDrop",
2545
+ category: "assessment",
2546
+ assessmentContract: true,
2547
+ h5pMachineName: "H5P.DragQuestion",
2548
+ h5pAlias: "Drag and Drop",
2549
+ description: "Drag items onto labeled targets.",
2550
+ props: [
2551
+ { name: "checkId", type: "CheckId", required: true, description: "Stable check id." },
2552
+ { name: "items", type: "DragItem[]", required: true, description: "Draggable items." },
2553
+ { name: "targets", type: "DropTarget[]", required: true, description: "Drop targets." },
2554
+ ...assessmentBehaviourProps
2555
+ ],
2556
+ requiredIds: ["checkId"],
2557
+ parentConstraints: ["Lesson", "AssessmentSequence"],
2558
+ a11y: {
2559
+ element: "section",
2560
+ ariaLabel: "Drag and Drop",
2561
+ keyboard: "Select item then activate target; drag also supported.",
2562
+ notes: "H5P Drag and Drop equivalent."
2563
+ },
2564
+ theming: { surface: "global-inherit", dataAttributes: ["data-lk-check-id"], stylingNotes: "Uses data-lk-check-id." },
2565
+ telemetry: { emits: ["assessment_answered", "assessment_completed"], requiresActiveLesson: true }
2566
+ },
2567
+ {
2568
+ type: "DragTheWords",
2569
+ category: "assessment",
2570
+ assessmentContract: true,
2571
+ h5pMachineName: "H5P.DragText",
2572
+ h5pAlias: "Drag the Words",
2573
+ description: "Drag words into inline blanks.",
2574
+ props: [
2575
+ { name: "checkId", type: "CheckId", required: true, description: "Stable check id." },
2576
+ { name: "template", type: "string", required: true, description: "Sentence with *blank* zones." },
2577
+ { name: "words", type: "string[]", required: true, description: "Draggable word bank." },
2578
+ ...assessmentBehaviourProps
2579
+ ],
2580
+ requiredIds: ["checkId"],
2581
+ parentConstraints: ["Lesson", "AssessmentSequence"],
2582
+ a11y: {
2583
+ element: "section",
2584
+ ariaLabel: "Drag the Words",
2585
+ keyboard: "Select word then activate zone.",
2586
+ notes: "H5P Drag the Words equivalent."
2587
+ },
2588
+ theming: { surface: "global-inherit", dataAttributes: ["data-lk-check-id"], stylingNotes: "Uses data-lk-check-id." },
2589
+ telemetry: { emits: ["assessment_answered", "assessment_completed"], requiresActiveLesson: true }
2590
+ },
2591
+ {
2592
+ type: "MarkTheWords",
2593
+ category: "assessment",
2594
+ assessmentContract: true,
2595
+ h5pMachineName: "H5P.MarkTheWords",
2596
+ h5pAlias: "Mark the Words",
2597
+ description: "Select correct words in a sentence.",
2598
+ props: [
2599
+ { name: "checkId", type: "CheckId", required: true, description: "Stable check id." },
2600
+ { name: "text", type: "string", required: true, description: "Source text." },
2601
+ { name: "correctWords", type: "string[]", required: true, description: "Words to mark." },
2602
+ ...assessmentBehaviourProps
2603
+ ],
2604
+ requiredIds: ["checkId"],
2605
+ parentConstraints: ["Lesson", "AssessmentSequence"],
2606
+ a11y: {
2607
+ element: "section",
2608
+ ariaLabel: "Mark the Words",
2609
+ keyboard: "Toggle words with buttons.",
2610
+ notes: "H5P Mark the Words equivalent."
2611
+ },
2612
+ theming: { surface: "global-inherit", dataAttributes: ["data-lk-check-id"], stylingNotes: "Uses data-lk-check-id." },
2613
+ telemetry: { emits: ["assessment_answered", "assessment_completed"], requiresActiveLesson: true }
2614
+ },
2615
+ {
2616
+ type: "AssessmentSequence",
2617
+ category: "container",
2618
+ h5pMachineName: "H5P.QuestionSet",
2619
+ h5pAlias: "Question Set",
2620
+ description: "Ordered sequence of contract-compliant assessments.",
2621
+ props: [
2622
+ { name: "children", type: "ReactNode", required: true, description: "Assessment blocks." },
2623
+ { name: "sequential", type: "boolean", required: false, description: "One question at a time." },
2624
+ ...assessmentBehaviourProps.filter((p) => p.name !== "passingScore")
2625
+ ],
2626
+ requiredIds: [],
2627
+ parentConstraints: ["Lesson"],
2628
+ a11y: {
2629
+ element: "section",
2630
+ ariaLabel: "Assessment sequence",
2631
+ keyboard: "Previous/Next navigation between steps.",
2632
+ notes: "H5P Question Set equivalent."
2633
+ },
2634
+ theming: { surface: "global-inherit", stylingNotes: "Container for assessments." },
2635
+ telemetry: { emits: [], manualTracking: "Child assessments emit assessment_* events." }
2636
+ }
2637
+ ];
2638
+ var BLOCK_CATALOG_V2 = [
2639
+ ...BLOCK_CATALOG,
2640
+ ...v2AssessmentEntries
2641
+ ];
2642
+ function cloneCatalogEntry(entry) {
2643
+ return {
1488
2644
  ...entry,
1489
2645
  props: entry.props.map((p) => ({ ...p })),
1490
2646
  aliases: entry.aliases ? [...entry.aliases] : void 0,
@@ -1499,23 +2655,38 @@ function buildBlockCatalog() {
1499
2655
  ...entry.telemetry,
1500
2656
  emits: [...entry.telemetry.emits]
1501
2657
  }
1502
- }));
2658
+ };
2659
+ }
2660
+ function buildBlockCatalog(opts) {
2661
+ const version = opts?.version ?? 2;
2662
+ const source = version === 2 ? BLOCK_CATALOG_V2 : BLOCK_CATALOG;
2663
+ return source.map((entry) => cloneCatalogEntry(entry));
1503
2664
  }
1504
- function getBlockCatalogEntry(type) {
1505
- return BLOCK_CATALOG.find((entry) => entry.type === type || entry.aliases?.includes(type));
2665
+ function getBlockCatalogEntry(type, opts) {
2666
+ const version = opts?.version ?? 2;
2667
+ const source = version === 2 ? BLOCK_CATALOG_V2 : BLOCK_CATALOG;
2668
+ return source.find((entry) => entry.type === type || entry.aliases?.includes(type));
1506
2669
  }
1507
2670
  // Annotate the CommonJS export names for ESM import in node:
1508
2671
  0 && (module.exports = {
2672
+ AssessmentSequence,
1509
2673
  BLOCK_CATALOG,
2674
+ BLOCK_CATALOG_V2,
1510
2675
  Course,
2676
+ DragAndDrop,
2677
+ DragTheWords,
2678
+ FillInTheBlanks,
1511
2679
  KnowledgeCheck,
1512
2680
  Lesson,
1513
2681
  LessonkitProvider,
2682
+ MarkTheWords,
1514
2683
  ProgressTracker,
1515
2684
  Quiz,
1516
2685
  Reflection,
1517
2686
  Scenario,
1518
2687
  ThemeProvider,
2688
+ TrueFalse,
2689
+ blockCatalogV2Version,
1519
2690
  blockCatalogVersion,
1520
2691
  buildBlockCatalog,
1521
2692
  buildTelemetryEvent,
@@ -1526,7 +2697,9 @@ function getBlockCatalogEntry(type) {
1526
2697
  defineLifecyclePlugin,
1527
2698
  defineTelemetryPlugin,
1528
2699
  getBlockCatalogEntry,
2700
+ resetAssessmentWarningsForTests,
1529
2701
  resetQuizWarningsForTests,
2702
+ useAssessmentState,
1530
2703
  useCompletion,
1531
2704
  useLessonkit,
1532
2705
  useProgress,