@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.js CHANGED
@@ -1,7 +1,30 @@
1
1
  // src/components.tsx
2
- import { useEffect as useEffect2, useId, useMemo as useMemo3, useRef as useRef2, useState as useState2 } from "react";
2
+ import { useEffect as useEffect2, useId, useMemo as useMemo4, useRef as useRef2, useState as useState2 } from "react";
3
3
  import { visuallyHiddenStyle } from "@lessonkit/accessibility";
4
4
 
5
+ // src/assessment/scoring.ts
6
+ function resolvePassingThreshold(passingScore, maxScore) {
7
+ return passingScore ?? maxScore;
8
+ }
9
+ function meetsPassingThreshold(score, maxScore, passingScore) {
10
+ const threshold = resolvePassingThreshold(passingScore, maxScore);
11
+ return score >= threshold;
12
+ }
13
+ function scoreFromCustom(custom, fallbackCorrect, fallbackMax = 1, passingScore) {
14
+ const maxScore = custom?.maxScore ?? fallbackMax;
15
+ if (custom?.passed !== void 0) {
16
+ const score2 = custom.passed ? custom.score ?? maxScore : custom.score ?? 0;
17
+ return { score: score2, maxScore, passed: custom.passed };
18
+ }
19
+ if (custom?.maxScore != null && custom.maxScore > 0 && custom.score != null) {
20
+ const passed2 = meetsPassingThreshold(custom.score, custom.maxScore, passingScore);
21
+ return { score: custom.score, maxScore: custom.maxScore, passed: passed2 };
22
+ }
23
+ const score = fallbackCorrect ? maxScore : 0;
24
+ const passed = meetsPassingThreshold(score, maxScore, passingScore);
25
+ return { score, maxScore, passed };
26
+ }
27
+
5
28
  // src/context.tsx
6
29
  import { createContext } from "react";
7
30
 
@@ -142,7 +165,40 @@ import {
142
165
 
143
166
  // src/runtime/courseStartedPipeline.ts
144
167
  import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement2 } from "@lessonkit/xapi";
145
- function emitCourseStartedNonTrackingPipeline(opts) {
168
+ function isDevEnvironment3() {
169
+ const g = globalThis;
170
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
171
+ }
172
+ function warnExtraSinkFailure(sinkId, err) {
173
+ if (isDevEnvironment3()) {
174
+ console.warn(
175
+ `[lessonkit] course_started extra sink "${sinkId}" failed:`,
176
+ err instanceof Error ? err.message : err
177
+ );
178
+ }
179
+ }
180
+ async function emitExtraSinks(sinks, event, emitCtx) {
181
+ await Promise.all(
182
+ sinks.map(async (sink) => {
183
+ let result;
184
+ try {
185
+ result = sink.emit(event, emitCtx);
186
+ } catch (err) {
187
+ warnExtraSinkFailure(sink.id, err);
188
+ throw err;
189
+ }
190
+ if (result != null && typeof result.then === "function") {
191
+ try {
192
+ await result;
193
+ } catch (err) {
194
+ warnExtraSinkFailure(sink.id, err);
195
+ throw err;
196
+ }
197
+ }
198
+ })
199
+ );
200
+ }
201
+ async function emitCourseStartedNonTrackingPipeline(opts) {
146
202
  let xapiStatementSent = false;
147
203
  if (!opts.skipXapi && opts.xapi) {
148
204
  const statement = telemetryEventToXAPIStatement2(opts.event);
@@ -157,9 +213,7 @@ function emitCourseStartedNonTrackingPipeline(opts) {
157
213
  sessionId: opts.event.sessionId,
158
214
  attemptId: opts.event.attemptId
159
215
  };
160
- for (const sink of opts.extraSinks ?? []) {
161
- sink.emit(opts.event, emitCtx);
162
- }
216
+ await emitExtraSinks(opts.extraSinks ?? [], opts.event, emitCtx);
163
217
  return { xapiStatementSent };
164
218
  }
165
219
 
@@ -209,8 +263,12 @@ async function disposeTrackingClient(client) {
209
263
  }
210
264
 
211
265
  // src/provider/useLessonkitProviderRuntime.ts
212
- var useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
266
+ var useIsoLayoutEffect = (
267
+ /* v8 ignore next -- SSR uses useEffect when window is unavailable */
268
+ typeof window !== "undefined" ? useLayoutEffect : useEffect
269
+ );
213
270
  var defaultStorage = createSessionStoragePort();
271
+ var courseStartedTrackingFlightKey = null;
214
272
  function isTrackingActive(tracking) {
215
273
  return tracking?.enabled !== false;
216
274
  }
@@ -233,15 +291,41 @@ function buildCourseStartedEvent(opts) {
233
291
  });
234
292
  return opts.pluginHost ? opts.pluginHost.runTelemetry(built, pluginCtx) : built;
235
293
  }
236
- function emitCourseStartedPipelineOnly(opts) {
294
+ async function emitCourseStartedToTracking(tracking, storage, sessionId, courseId, event, shouldCommit) {
295
+ const flightKey = `${sessionId}:${courseId}`;
296
+ if (hasCourseStartedEmittedToTracking(storage, sessionId, courseId)) {
297
+ return true;
298
+ }
299
+ if (courseStartedTrackingFlightKey === flightKey) {
300
+ return false;
301
+ }
302
+ courseStartedTrackingFlightKey = flightKey;
303
+ try {
304
+ if (shouldCommit && !shouldCommit()) return false;
305
+ tracking.track(event);
306
+ await tracking.flush?.();
307
+ if (shouldCommit && !shouldCommit()) return false;
308
+ markCourseStartedEmittedToTracking(storage, sessionId, courseId);
309
+ return true;
310
+ } catch {
311
+ return false;
312
+ } finally {
313
+ if (courseStartedTrackingFlightKey === flightKey) {
314
+ courseStartedTrackingFlightKey = null;
315
+ }
316
+ }
317
+ }
318
+ async function emitCourseStartedPipelineOnly(opts) {
237
319
  try {
238
- const { xapiStatementSent } = emitCourseStartedNonTrackingPipeline({
320
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
321
+ const { xapiStatementSent } = await emitCourseStartedNonTrackingPipeline({
239
322
  event: opts.event,
240
323
  xapi: opts.xapi,
241
324
  lxpackBridge: opts.lxpackBridge,
242
325
  extraSinks: opts.extraSinks,
243
326
  skipXapi: opts.skipXapi
244
327
  });
328
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
245
329
  markCourseStarted(opts.storage, opts.sessionId, opts.courseId);
246
330
  markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId);
247
331
  if (xapiStatementSent) {
@@ -252,47 +336,41 @@ function emitCourseStartedPipelineOnly(opts) {
252
336
  return "failed";
253
337
  }
254
338
  }
255
- function emitCourseStarted(opts) {
339
+ async function emitCourseStarted(opts) {
256
340
  const event = buildCourseStartedEvent(opts);
257
341
  if (event === null) return "filtered";
258
- const trackingAlreadyEmitted = hasCourseStartedEmittedToTracking(
342
+ const tracked = await emitCourseStartedToTracking(
343
+ opts.tracking,
259
344
  opts.storage,
260
345
  opts.sessionId,
261
- opts.courseId
346
+ opts.courseId,
347
+ event,
348
+ opts.shouldCommit
262
349
  );
263
- if (!trackingAlreadyEmitted) {
264
- try {
265
- opts.tracking.track(event);
266
- markCourseStartedEmittedToTracking(opts.storage, opts.sessionId, opts.courseId);
267
- } catch {
268
- return "failed";
269
- }
270
- }
350
+ if (!tracked) return "failed";
271
351
  return emitCourseStartedPipelineOnly({
272
352
  ...opts,
273
353
  event,
274
354
  skipXapi: opts.skipXapi,
275
- onXapiStatementSent: opts.onXapiStatementSent
355
+ onXapiStatementSent: opts.onXapiStatementSent,
356
+ shouldCommit: opts.shouldCommit
276
357
  });
277
358
  }
278
- function emitCourseStartedToTrackingOnly(opts) {
359
+ async function emitCourseStartedToTrackingOnly(opts) {
279
360
  const event = buildCourseStartedEvent(opts);
280
361
  if (event === null) return "filtered";
281
- const trackingAlreadyEmitted = hasCourseStartedEmittedToTracking(
362
+ const tracked = await emitCourseStartedToTracking(
363
+ opts.tracking,
282
364
  opts.storage,
283
365
  opts.sessionId,
284
- opts.courseId
366
+ opts.courseId,
367
+ event,
368
+ opts.shouldCommit
285
369
  );
286
- if (!trackingAlreadyEmitted) {
287
- try {
288
- opts.tracking.track(event);
289
- markCourseStartedEmittedToTracking(opts.storage, opts.sessionId, opts.courseId);
290
- } catch {
291
- return "failed";
292
- }
293
- }
370
+ if (!tracked) return "failed";
294
371
  try {
295
- emitCourseStartedNonTrackingPipeline({
372
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
373
+ await emitCourseStartedNonTrackingPipeline({
296
374
  event,
297
375
  xapi: null,
298
376
  lxpackBridge: opts.lxpackBridge,
@@ -305,7 +383,7 @@ function emitCourseStartedToTrackingOnly(opts) {
305
383
  return "failed";
306
384
  }
307
385
  }
308
- function emitPendingCourseStarted(opts) {
386
+ async function emitPendingCourseStarted(opts) {
309
387
  const trackingEmitted = hasCourseStartedEmittedToTracking(
310
388
  opts.storage,
311
389
  opts.sessionId,
@@ -328,6 +406,9 @@ function emitPendingCourseStarted(opts) {
328
406
  opts.sessionId,
329
407
  opts.courseId
330
408
  );
409
+ if (sessionStarted && trackingEmitted && pipelineDelivered) {
410
+ return "emitted";
411
+ }
331
412
  if (sessionStarted && trackingEmitted && !pipelineDelivered) {
332
413
  const event = buildCourseStartedEvent(opts);
333
414
  if (event === null) return "filtered";
@@ -379,6 +460,7 @@ function useLessonkitProviderRuntime(config) {
379
460
  pluginHostRef.current = pluginHost;
380
461
  const progressRef = useRef(createProgressController());
381
462
  const courseStartedEmittedToSinkRef = useRef(false);
463
+ const courseStartedEmitGenerationRef = useRef(0);
382
464
  const prevCourseIdForProgressRef = useRef(normalizedCourseId);
383
465
  const pendingCourseIdResetRef = useRef(false);
384
466
  const prevUseV2RuntimeRef = useRef(useV2Runtime);
@@ -398,6 +480,7 @@ function useLessonkitProviderRuntime(config) {
398
480
  }
399
481
  pendingCourseIdResetRef.current = true;
400
482
  courseStartedEmittedToSinkRef.current = false;
483
+ courseStartedEmitGenerationRef.current += 1;
401
484
  } else if (useV2Runtime && !headlessRef.current) {
402
485
  headlessRef.current = createLessonkitRuntime({
403
486
  courseId: normalizedCourseId,
@@ -415,6 +498,7 @@ function useLessonkitProviderRuntime(config) {
415
498
  }
416
499
  pendingCourseIdResetRef.current = true;
417
500
  courseStartedEmittedToSinkRef.current = false;
501
+ courseStartedEmitGenerationRef.current += 1;
418
502
  }
419
503
  if (useV2Runtime && headlessRef.current) {
420
504
  progressRef.current = headlessRef.current.progress;
@@ -528,7 +612,10 @@ function useLessonkitProviderRuntime(config) {
528
612
  const baseSink = normalizedConfig.tracking?.sink;
529
613
  const userBatchSink = normalizedConfig.tracking?.batchSink;
530
614
  assertTrackingSinkConfig(normalizedConfig.tracking);
531
- const sink = pluginHostRef.current && baseSink ? pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink : baseSink;
615
+ const sink = pluginHostRef.current && baseSink ? (
616
+ /* v8 ignore next -- composeTrackingSink may return null; fall back to base sink */
617
+ pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink
618
+ ) : baseSink;
532
619
  const batchSink = pluginHostRef.current && userBatchSink ? async (events) => {
533
620
  const host = pluginHostRef.current;
534
621
  const ctx = buildCurrentPluginCtx();
@@ -552,30 +639,39 @@ function useLessonkitProviderRuntime(config) {
552
639
  const sessionId = sessionIdRef.current;
553
640
  const cid = courseIdRef.current;
554
641
  const trackingActive = isTrackingActive(normalizedConfig.tracking);
642
+ const courseStartedFullySettled = hasCourseStartedEmittedToTracking(defaultStorage, sessionId, cid) && hasCourseStarted(defaultStorage, sessionId, cid) && hasCourseStartedPipelineDelivered(defaultStorage, sessionId, cid);
555
643
  if (!trackingActive) {
556
644
  courseStartedEmittedToSinkRef.current = false;
557
- } else if (!courseStartedEmittedToSinkRef.current) {
558
- const result = emitPendingCourseStarted({
559
- pluginHost: pluginHostRef.current,
560
- tracking: next,
561
- xapi: xapiRef.current,
562
- storage: defaultStorage,
563
- sessionId,
564
- courseId: cid,
565
- attemptId: attemptIdRef.current,
566
- user: userRef.current,
567
- lxpackBridge: lxpackBridgeModeRef.current,
568
- extraSinks: extraSinksRef.current,
569
- skipXapi: xapiCourseStartedSentOnClientRef.current,
570
- onXapiStatementSent: () => {
571
- xapiCourseStartedSentOnClientRef.current = true;
572
- }
573
- });
574
- courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
575
- } else if (trackingActive) {
645
+ } else if (courseStartedFullySettled) {
576
646
  courseStartedEmittedToSinkRef.current = true;
647
+ } else if (!courseStartedEmittedToSinkRef.current) {
648
+ const generation = ++courseStartedEmitGenerationRef.current;
649
+ const shouldCommit = () => generation === courseStartedEmitGenerationRef.current;
650
+ void (async () => {
651
+ if (generation !== courseStartedEmitGenerationRef.current) return;
652
+ const result = await emitPendingCourseStarted({
653
+ pluginHost: pluginHostRef.current,
654
+ tracking: next,
655
+ xapi: xapiRef.current,
656
+ storage: defaultStorage,
657
+ sessionId,
658
+ courseId: cid,
659
+ attemptId: attemptIdRef.current,
660
+ user: userRef.current,
661
+ lxpackBridge: lxpackBridgeModeRef.current,
662
+ extraSinks: extraSinksRef.current,
663
+ skipXapi: xapiCourseStartedSentOnClientRef.current,
664
+ onXapiStatementSent: () => {
665
+ xapiCourseStartedSentOnClientRef.current = true;
666
+ },
667
+ shouldCommit
668
+ });
669
+ if (generation !== courseStartedEmitGenerationRef.current) return;
670
+ courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
671
+ })();
577
672
  }
578
673
  return () => {
674
+ courseStartedEmitGenerationRef.current += 1;
579
675
  if (prev !== trackingRef.current) {
580
676
  void disposeTrackingClient(prev);
581
677
  }
@@ -652,7 +748,7 @@ function useLessonkitProviderRuntime(config) {
652
748
  } catch {
653
749
  }
654
750
  if (!courseStartedEmittedToSinkRef.current) {
655
- const result = emitPendingCourseStarted({
751
+ const result = await emitPendingCourseStarted({
656
752
  pluginHost: pluginHostRef.current,
657
753
  tracking: trackingRef.current,
658
754
  xapi: xapiRef.current,
@@ -678,7 +774,10 @@ function useLessonkitProviderRuntime(config) {
678
774
  [track]
679
775
  );
680
776
  const completeLesson = useCallback(
681
- (lessonId) => {
777
+ (lessonId, opts) => {
778
+ if (opts?.courseId !== void 0 && opts.courseId !== courseIdRef.current) {
779
+ return;
780
+ }
682
781
  if (useV2Runtime && headlessRef.current) {
683
782
  headlessRef.current.completeLesson(lessonId, emitLifecycleEvent);
684
783
  syncProgress();
@@ -853,7 +952,27 @@ function LessonkitProvider(props) {
853
952
  }
854
953
 
855
954
  // src/hooks.ts
856
- import { useContext, useMemo as useMemo2 } from "react";
955
+ import { useContext, useMemo as useMemo3 } from "react";
956
+
957
+ // src/assessment/useAssessmentState.ts
958
+ import { useMemo as useMemo2 } from "react";
959
+ function useAssessmentState(enclosingLessonId) {
960
+ const { track } = useLessonkit();
961
+ const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
962
+ return useMemo2(
963
+ () => ({
964
+ answer: (data) => {
965
+ track("assessment_answered", data, trackOpts);
966
+ },
967
+ complete: (data) => {
968
+ track("assessment_completed", data, trackOpts);
969
+ }
970
+ }),
971
+ [track, enclosingLessonId]
972
+ );
973
+ }
974
+
975
+ // src/hooks.ts
857
976
  function useLessonkit() {
858
977
  const ctx = useContext(LessonkitContext);
859
978
  if (!ctx) throw new Error("LessonKit: missing LessonkitProvider");
@@ -865,16 +984,16 @@ function useProgress() {
865
984
  }
866
985
  function useTracking() {
867
986
  const { track } = useLessonkit();
868
- return useMemo2(() => ({ track }), [track]);
987
+ return useMemo3(() => ({ track }), [track]);
869
988
  }
870
989
  function useCompletion() {
871
990
  const { completeLesson, completeCourse } = useLessonkit();
872
- return useMemo2(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
991
+ return useMemo3(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
873
992
  }
874
993
  function useQuizState(enclosingLessonId) {
875
994
  const { track } = useLessonkit();
876
995
  const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
877
- return useMemo2(
996
+ return useMemo3(
878
997
  () => ({
879
998
  answer: (opts) => {
880
999
  track("quiz_answered", opts, trackOpts);
@@ -896,7 +1015,7 @@ function useEnclosingLessonId() {
896
1015
 
897
1016
  // src/runtime/validateComponentId.ts
898
1017
  import { assertValidId as assertValidId2 } from "@lessonkit/core";
899
- function isDevEnvironment3() {
1018
+ function isDevEnvironment4() {
900
1019
  const g = globalThis;
901
1020
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
902
1021
  }
@@ -912,7 +1031,7 @@ function normalizeComponentId(id, path) {
912
1031
  var mountCounts = /* @__PURE__ */ new Map();
913
1032
  var warnedConcurrentLessons = false;
914
1033
  function registerLessonMount(lessonId) {
915
- if (isDevEnvironment3() && mountCounts.size > 0 && !mountCounts.has(lessonId) && !warnedConcurrentLessons) {
1034
+ if (isDevEnvironment4() && mountCounts.size > 0 && !mountCounts.has(lessonId) && !warnedConcurrentLessons) {
916
1035
  warnedConcurrentLessons = true;
917
1036
  console.warn(
918
1037
  "[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."
@@ -939,8 +1058,8 @@ function resetQuizWarningsForTests() {
939
1058
  warnedQuizOutsideLesson = false;
940
1059
  }
941
1060
  function Course(props) {
942
- const courseId = useMemo3(() => normalizeComponentId(props.courseId, "courseId"), [props.courseId]);
943
- const providerConfig = useMemo3(
1061
+ const courseId = useMemo4(() => normalizeComponentId(props.courseId, "courseId"), [props.courseId]);
1062
+ const providerConfig = useMemo4(
944
1063
  () => ({ ...props.config, courseId }),
945
1064
  [props.config, courseId]
946
1065
  );
@@ -950,14 +1069,23 @@ function Course(props) {
950
1069
  ] }) });
951
1070
  }
952
1071
  function Lesson(props) {
953
- const lessonId = useMemo3(() => normalizeComponentId(props.lessonId, "lessonId"), [props.lessonId]);
1072
+ const lessonId = useMemo4(() => normalizeComponentId(props.lessonId, "lessonId"), [props.lessonId]);
954
1073
  const autoComplete = props.autoCompleteOnUnmount !== false;
955
1074
  const { setActiveLesson, config } = useLessonkit();
956
1075
  const { completeLesson } = useCompletion();
957
1076
  const lessonMountGenerationRef = useRef2(0);
1077
+ const liveCourseIdRef = useRef2(config.courseId);
1078
+ liveCourseIdRef.current = config.courseId;
958
1079
  useEffect2(() => {
959
1080
  const unregister = registerLessonMount(lessonId);
960
1081
  const generation = ++lessonMountGenerationRef.current;
1082
+ const mountedCourseId = config.courseId;
1083
+ let effectSurvivedTick = false;
1084
+ queueMicrotask(() => {
1085
+ queueMicrotask(() => {
1086
+ effectSurvivedTick = true;
1087
+ });
1088
+ });
961
1089
  setActiveLesson(lessonId);
962
1090
  return () => {
963
1091
  unregister();
@@ -966,8 +1094,10 @@ function Lesson(props) {
966
1094
  }
967
1095
  if (!autoComplete) return;
968
1096
  queueMicrotask(() => {
1097
+ if (!effectSurvivedTick) return;
969
1098
  if (lessonMountGenerationRef.current !== generation) return;
970
- completeLesson(lessonId);
1099
+ if (liveCourseIdRef.current !== mountedCourseId) return;
1100
+ completeLesson(lessonId, { courseId: mountedCourseId });
971
1101
  });
972
1102
  };
973
1103
  }, [lessonId, config.courseId, setActiveLesson, completeLesson, autoComplete]);
@@ -977,14 +1107,14 @@ function Lesson(props) {
977
1107
  ] }) });
978
1108
  }
979
1109
  function Scenario(props) {
980
- const blockId = useMemo3(
1110
+ const blockId = useMemo4(
981
1111
  () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
982
1112
  [props.blockId]
983
1113
  );
984
1114
  return /* @__PURE__ */ jsx2("section", { "aria-label": "Scenario", "data-lk-block-id": blockId, children: props.children });
985
1115
  }
986
1116
  function Reflection(props) {
987
- const blockId = useMemo3(
1117
+ const blockId = useMemo4(
988
1118
  () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
989
1119
  [props.blockId]
990
1120
  );
@@ -1026,11 +1156,10 @@ function KnowledgeCheck(props) {
1026
1156
  );
1027
1157
  }
1028
1158
  function Quiz(props) {
1029
- const checkId = useMemo3(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1030
1159
  const enclosingLessonId = useEnclosingLessonId();
1031
1160
  const missingLesson = enclosingLessonId === void 0;
1032
1161
  useEffect2(() => {
1033
- if (!missingLesson || isDevEnvironment3()) return;
1162
+ if (!missingLesson || isDevEnvironment4()) return;
1034
1163
  if (!warnedQuizOutsideLesson) {
1035
1164
  warnedQuizOutsideLesson = true;
1036
1165
  console.error(
@@ -1038,9 +1167,17 @@ function Quiz(props) {
1038
1167
  );
1039
1168
  }
1040
1169
  }, [missingLesson]);
1041
- if (missingLesson && isDevEnvironment3()) {
1170
+ if (missingLesson && isDevEnvironment4()) {
1042
1171
  throw new Error("[lessonkit] <Quiz> must be wrapped in <Lesson>");
1043
1172
  }
1173
+ if (missingLesson) {
1174
+ return /* @__PURE__ */ jsx2("section", { role: "alert", "aria-label": "Quiz configuration error", "data-lk-check-id": props.checkId, children: /* @__PURE__ */ jsx2("p", { children: "Quiz must be placed inside a Lesson." }) });
1175
+ }
1176
+ return /* @__PURE__ */ jsx2(QuizInner, { ...props, enclosingLessonId });
1177
+ }
1178
+ function QuizInner(props) {
1179
+ const { enclosingLessonId } = props;
1180
+ const checkId = useMemo4(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1044
1181
  const quiz = useQuizState(enclosingLessonId);
1045
1182
  const { plugins, config, session } = useLessonkit();
1046
1183
  const [selected, setSelected] = useState2(null);
@@ -1058,14 +1195,11 @@ function Quiz(props) {
1058
1195
  const isChoiceCorrect = (choice, custom) => {
1059
1196
  if (!custom) return choice === props.answer;
1060
1197
  if (custom.passed !== void 0) return custom.passed;
1061
- if (custom.maxScore != null && custom.maxScore > 0) {
1062
- return custom.score / custom.maxScore >= 1;
1198
+ if (custom.maxScore != null && custom.maxScore > 0 && custom.score != null) {
1199
+ return meetsPassingThreshold(custom.score, custom.maxScore, props.passingScore);
1063
1200
  }
1064
1201
  return choice === props.answer;
1065
1202
  };
1066
- if (missingLesson) {
1067
- return /* @__PURE__ */ jsx2("section", { role: "alert", "aria-label": "Quiz configuration error", "data-lk-check-id": checkId, children: /* @__PURE__ */ jsx2("p", { children: "Quiz must be placed inside a Lesson." }) });
1068
- }
1069
1203
  const passed = quizPassed;
1070
1204
  return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": checkId, children: [
1071
1205
  /* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
@@ -1112,7 +1246,7 @@ function Quiz(props) {
1112
1246
  const maxScore = custom?.maxScore ?? 1;
1113
1247
  quiz.complete({
1114
1248
  checkId,
1115
- score: custom?.score ?? 1,
1249
+ score: custom?.score ?? maxScore,
1116
1250
  maxScore,
1117
1251
  passingScore: props.passingScore ?? maxScore
1118
1252
  });
@@ -1155,6 +1289,859 @@ function ProgressTracker(props) {
1155
1289
  ] }) });
1156
1290
  }
1157
1291
 
1292
+ // src/blocks/TrueFalse.tsx
1293
+ import React5, { forwardRef, useEffect as useEffect4, useImperativeHandle, useMemo as useMemo6, useRef as useRef4, useState as useState3 } from "react";
1294
+
1295
+ // src/assessment/AssessmentLessonGuard.tsx
1296
+ import { useEffect as useEffect3 } from "react";
1297
+ import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
1298
+ var warnedAssessmentOutsideLesson = false;
1299
+ function resetAssessmentWarningsForTests() {
1300
+ warnedAssessmentOutsideLesson = false;
1301
+ }
1302
+ function AssessmentLessonGuard(props) {
1303
+ const enclosingLessonId = useEnclosingLessonId();
1304
+ const missingLesson = enclosingLessonId === void 0;
1305
+ useEffect3(() => {
1306
+ if (!missingLesson || isDevEnvironment4()) return;
1307
+ if (!warnedAssessmentOutsideLesson) {
1308
+ warnedAssessmentOutsideLesson = true;
1309
+ console.error(
1310
+ `[lessonkit] <${props.blockLabel}> must be wrapped in <Lesson>; assessment telemetry will not be emitted.`
1311
+ );
1312
+ }
1313
+ }, [missingLesson, props.blockLabel]);
1314
+ if (missingLesson && isDevEnvironment4()) {
1315
+ throw new Error(`[lessonkit] <${props.blockLabel}> must be wrapped in <Lesson>`);
1316
+ }
1317
+ if (missingLesson) {
1318
+ return /* @__PURE__ */ jsx3("section", { role: "alert", "aria-label": `${props.blockLabel} configuration error`, "data-lk-check-id": props.checkId, children: /* @__PURE__ */ jsxs2("p", { children: [
1319
+ props.blockLabel,
1320
+ " must be placed inside a Lesson."
1321
+ ] }) });
1322
+ }
1323
+ return /* @__PURE__ */ jsx3(Fragment, { children: props.children(enclosingLessonId) });
1324
+ }
1325
+
1326
+ // src/assessment/AssessmentSequenceContext.tsx
1327
+ import React4, { createContext as createContext3, useCallback as useCallback2, useContext as useContext3, useMemo as useMemo5, useRef as useRef3 } from "react";
1328
+ import { jsx as jsx4 } from "react/jsx-runtime";
1329
+ var AssessmentSequenceContext = createContext3(null);
1330
+ function AssessmentSequenceProvider({ children }) {
1331
+ const registryRef = useRef3(/* @__PURE__ */ new Map());
1332
+ const register = useCallback2((checkId, handle) => {
1333
+ registryRef.current.set(checkId, handle);
1334
+ return () => {
1335
+ registryRef.current.delete(checkId);
1336
+ };
1337
+ }, []);
1338
+ const value = useMemo5(
1339
+ () => ({
1340
+ register,
1341
+ getHandles: () => registryRef.current
1342
+ }),
1343
+ [register]
1344
+ );
1345
+ return /* @__PURE__ */ jsx4(AssessmentSequenceContext.Provider, { value, children });
1346
+ }
1347
+ function useAssessmentSequenceRegistry() {
1348
+ return useContext3(AssessmentSequenceContext);
1349
+ }
1350
+ function useRegisterAssessmentHandle(checkId, handle) {
1351
+ const ctx = useAssessmentSequenceRegistry();
1352
+ React4.useEffect(() => {
1353
+ if (!ctx || !handle) return;
1354
+ return ctx.register(checkId, handle);
1355
+ }, [ctx, checkId, handle]);
1356
+ }
1357
+
1358
+ // src/blocks/TrueFalse.tsx
1359
+ import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
1360
+ var INTERACTION = "trueFalse";
1361
+ function TrueFalseInner(props, ref) {
1362
+ const { enclosingLessonId } = props;
1363
+ const checkId = useMemo6(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1364
+ const assessment = useAssessmentState(enclosingLessonId);
1365
+ const { plugins, config, session } = useLessonkit();
1366
+ const [selected, setSelected] = useState3(null);
1367
+ const [selectionCorrect, setSelectionCorrect] = useState3(null);
1368
+ const [showSolutions, setShowSolutions] = useState3(false);
1369
+ const [passed, setPassed] = useState3(false);
1370
+ const completedRef = useRef4(false);
1371
+ const questionId = React5.useId();
1372
+ const reset = () => {
1373
+ completedRef.current = false;
1374
+ setPassed(false);
1375
+ setSelected(null);
1376
+ setSelectionCorrect(null);
1377
+ setShowSolutions(false);
1378
+ };
1379
+ useEffect4(() => {
1380
+ reset();
1381
+ }, [checkId, props.answer, props.question, config.courseId, enclosingLessonId]);
1382
+ const handle = useMemo6(() => {
1383
+ const maxScore = 1;
1384
+ const score = passed ? maxScore : selected === null ? 0 : selected === props.answer ? maxScore : 0;
1385
+ return {
1386
+ getScore: () => score,
1387
+ getMaxScore: () => maxScore,
1388
+ getAnswerGiven: () => selected !== null,
1389
+ resetTask: reset,
1390
+ showSolutions: () => setShowSolutions(true),
1391
+ getXAPIData: () => ({
1392
+ checkId,
1393
+ interactionType: INTERACTION,
1394
+ response: selected ?? void 0,
1395
+ correct: selected === props.answer,
1396
+ score,
1397
+ maxScore
1398
+ })
1399
+ };
1400
+ }, [checkId, passed, props.answer, selected]);
1401
+ useImperativeHandle(ref, () => handle, [handle]);
1402
+ useRegisterAssessmentHandle(checkId, handle);
1403
+ const submit = (value) => {
1404
+ if (passed && !props.enableRetry) return;
1405
+ setSelected(value);
1406
+ const pluginCtx = buildPluginContext({
1407
+ courseId: config.courseId,
1408
+ sessionId: session.sessionId,
1409
+ attemptId: session.attemptId,
1410
+ user: session.user
1411
+ });
1412
+ const custom = plugins?.scoreAssessment(
1413
+ { checkId, lessonId: enclosingLessonId, response: value },
1414
+ pluginCtx
1415
+ ) ?? null;
1416
+ const correct = value === props.answer;
1417
+ const scored = scoreFromCustom(custom, correct, 1, props.passingScore);
1418
+ setSelectionCorrect(scored.passed);
1419
+ assessment.answer({
1420
+ checkId,
1421
+ interactionType: INTERACTION,
1422
+ question: props.question,
1423
+ response: value,
1424
+ correct: scored.passed
1425
+ });
1426
+ if (scored.passed && !completedRef.current) {
1427
+ completedRef.current = true;
1428
+ setPassed(true);
1429
+ assessment.complete({
1430
+ checkId,
1431
+ interactionType: INTERACTION,
1432
+ score: scored.score,
1433
+ maxScore: scored.maxScore,
1434
+ passingScore: props.passingScore ?? scored.maxScore
1435
+ });
1436
+ }
1437
+ };
1438
+ const reveal = showSolutions || passed && props.enableSolutionsButton;
1439
+ return /* @__PURE__ */ jsxs3("section", { "aria-label": "True or False", "data-lk-check-id": checkId, children: [
1440
+ /* @__PURE__ */ jsx5("p", { id: questionId, children: props.question }),
1441
+ /* @__PURE__ */ jsxs3("fieldset", { "aria-labelledby": questionId, children: [
1442
+ /* @__PURE__ */ jsx5("legend", { className: "lk-visually-hidden", children: "True or False" }),
1443
+ /* @__PURE__ */ jsxs3("label", { style: { display: "block", marginRight: "1rem" }, children: [
1444
+ /* @__PURE__ */ jsx5(
1445
+ "input",
1446
+ {
1447
+ type: "radio",
1448
+ name: `${questionId}-tf`,
1449
+ checked: selected === true,
1450
+ disabled: passed && !props.enableRetry,
1451
+ onChange: () => submit(true)
1452
+ }
1453
+ ),
1454
+ "True"
1455
+ ] }),
1456
+ /* @__PURE__ */ jsxs3("label", { style: { display: "block" }, children: [
1457
+ /* @__PURE__ */ jsx5(
1458
+ "input",
1459
+ {
1460
+ type: "radio",
1461
+ name: `${questionId}-tf`,
1462
+ checked: selected === false,
1463
+ disabled: passed && !props.enableRetry,
1464
+ onChange: () => submit(false)
1465
+ }
1466
+ ),
1467
+ "False"
1468
+ ] })
1469
+ ] }),
1470
+ reveal ? /* @__PURE__ */ jsxs3("p", { children: [
1471
+ "Correct answer: ",
1472
+ /* @__PURE__ */ jsx5("strong", { children: props.answer ? "True" : "False" })
1473
+ ] }) : null,
1474
+ selected !== null && selectionCorrect !== null ? /* @__PURE__ */ jsx5("p", { role: "status", "aria-live": "polite", children: selectionCorrect ? "Correct" : "Try again" }) : null,
1475
+ props.enableRetry && passed ? /* @__PURE__ */ jsx5("button", { type: "button", onClick: reset, children: "Try again" }) : null,
1476
+ props.enableSolutionsButton && !reveal ? /* @__PURE__ */ jsx5("button", { type: "button", onClick: () => setShowSolutions(true), children: "Show solution" }) : null
1477
+ ] });
1478
+ }
1479
+ var TrueFalseInnerForwarded = forwardRef(TrueFalseInner);
1480
+ var TrueFalse = forwardRef(function TrueFalse2(props, ref) {
1481
+ return /* @__PURE__ */ jsx5(AssessmentLessonGuard, { blockLabel: "TrueFalse", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ jsx5(TrueFalseInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
1482
+ });
1483
+
1484
+ // src/blocks/MarkTheWords.tsx
1485
+ import React6, { forwardRef as forwardRef2, useEffect as useEffect5, useImperativeHandle as useImperativeHandle2, useMemo as useMemo7, useRef as useRef5, useState as useState4 } from "react";
1486
+ import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
1487
+ var INTERACTION2 = "markTheWords";
1488
+ function tokenize(text) {
1489
+ return text.split(/(\s+)/).filter((t) => t.length > 0);
1490
+ }
1491
+ function MarkTheWordsInner(props, ref) {
1492
+ const checkId = useMemo7(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1493
+ const assessment = useAssessmentState(props.enclosingLessonId);
1494
+ const tokens = useMemo7(() => tokenize(props.text), [props.text]);
1495
+ const correctSet = useMemo7(
1496
+ () => new Set(props.correctWords.map((w) => w.toLowerCase())),
1497
+ [props.correctWords]
1498
+ );
1499
+ const [marked, setMarked] = useState4(() => /* @__PURE__ */ new Set());
1500
+ const [passed, setPassed] = useState4(false);
1501
+ const [showSolutions, setShowSolutions] = useState4(false);
1502
+ const completedRef = useRef5(false);
1503
+ const reset = () => {
1504
+ completedRef.current = false;
1505
+ setPassed(false);
1506
+ setMarked(/* @__PURE__ */ new Set());
1507
+ setShowSolutions(false);
1508
+ };
1509
+ useEffect5(() => {
1510
+ reset();
1511
+ }, [checkId, props.text, props.correctWords.join("\0")]);
1512
+ const selectableIndices = useMemo7(() => {
1513
+ const indices = [];
1514
+ tokens.forEach((t, i) => {
1515
+ if (!/^\s+$/.test(t) && correctSet.has(t.toLowerCase())) indices.push(i);
1516
+ });
1517
+ return indices;
1518
+ }, [tokens, correctSet]);
1519
+ const hasTargets = selectableIndices.length > 0;
1520
+ const allMarked = hasTargets && selectableIndices.every((i) => marked.has(i));
1521
+ const maxScore = selectableIndices.length;
1522
+ const score = allMarked ? maxScore : marked.size;
1523
+ const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
1524
+ const handle = useMemo7(() => {
1525
+ const handleMax = maxScore || 1;
1526
+ return {
1527
+ getScore: () => score,
1528
+ getMaxScore: () => handleMax,
1529
+ getAnswerGiven: () => marked.size > 0,
1530
+ resetTask: reset,
1531
+ showSolutions: () => setShowSolutions(true),
1532
+ getXAPIData: () => ({
1533
+ checkId,
1534
+ interactionType: INTERACTION2,
1535
+ response: [...marked].map((i) => tokens[i]),
1536
+ correct: passedThreshold,
1537
+ score,
1538
+ maxScore: handleMax
1539
+ })
1540
+ };
1541
+ }, [checkId, marked, maxScore, passedThreshold, score, tokens]);
1542
+ useImperativeHandle2(ref, () => handle, [handle]);
1543
+ useRegisterAssessmentHandle(checkId, handle);
1544
+ const toggle = (index) => {
1545
+ if (passed && !props.enableRetry) return;
1546
+ setMarked((prev) => {
1547
+ const next = new Set(prev);
1548
+ if (next.has(index)) next.delete(index);
1549
+ else next.add(index);
1550
+ return next;
1551
+ });
1552
+ };
1553
+ useEffect5(() => {
1554
+ if (!hasTargets) {
1555
+ if (isDevEnvironment4()) {
1556
+ console.warn(
1557
+ "[lessonkit] MarkTheWords: no tokens match correctWords",
1558
+ props.correctWords
1559
+ );
1560
+ }
1561
+ return;
1562
+ }
1563
+ if (!passedThreshold || completedRef.current) return;
1564
+ completedRef.current = true;
1565
+ setPassed(true);
1566
+ assessment.answer({
1567
+ checkId,
1568
+ interactionType: INTERACTION2,
1569
+ question: props.text,
1570
+ response: [...marked].map((i) => tokens[i]),
1571
+ correct: true
1572
+ });
1573
+ assessment.complete({
1574
+ checkId,
1575
+ interactionType: INTERACTION2,
1576
+ score,
1577
+ maxScore,
1578
+ passingScore: props.passingScore ?? maxScore
1579
+ });
1580
+ }, [
1581
+ assessment,
1582
+ checkId,
1583
+ hasTargets,
1584
+ marked,
1585
+ maxScore,
1586
+ passedThreshold,
1587
+ props.passingScore,
1588
+ props.correctWords,
1589
+ props.text,
1590
+ score,
1591
+ tokens
1592
+ ]);
1593
+ return /* @__PURE__ */ jsxs4("section", { "aria-label": "Mark the Words", "data-lk-check-id": checkId, children: [
1594
+ !hasTargets ? /* @__PURE__ */ jsxs4("p", { role: "alert", children: [
1595
+ "No words in this sentence match ",
1596
+ /* @__PURE__ */ jsx6("code", { children: "correctWords" }),
1597
+ ". Check spelling and capitalization in the source text."
1598
+ ] }) : null,
1599
+ /* @__PURE__ */ jsx6("p", { id: `${checkId}-hint`, children: "Select the correct words in the sentence." }),
1600
+ /* @__PURE__ */ jsx6("p", { "aria-describedby": `${checkId}-hint`, children: tokens.map((token, i) => {
1601
+ const isWord = !/^\s+$/.test(token);
1602
+ const isTarget = isWord && correctSet.has(token.toLowerCase());
1603
+ if (!isTarget) return /* @__PURE__ */ jsx6(React6.Fragment, { children: token }, i);
1604
+ const selected = marked.has(i);
1605
+ const solution = showSolutions || passed && props.enableSolutionsButton;
1606
+ return /* @__PURE__ */ jsx6(
1607
+ "button",
1608
+ {
1609
+ type: "button",
1610
+ "data-testid": `mark-word-${i}`,
1611
+ "aria-pressed": selected,
1612
+ disabled: passed && !props.enableRetry,
1613
+ onClick: () => toggle(i),
1614
+ style: {
1615
+ margin: "0 0.1em",
1616
+ textDecoration: solution ? "underline" : void 0,
1617
+ fontWeight: selected || solution ? "bold" : void 0
1618
+ },
1619
+ children: token
1620
+ },
1621
+ i
1622
+ );
1623
+ }) }),
1624
+ allMarked ? /* @__PURE__ */ jsx6("p", { role: "status", "aria-live": "polite", children: "Correct" }) : null,
1625
+ props.enableRetry && passed ? /* @__PURE__ */ jsx6("button", { type: "button", onClick: reset, children: "Try again" }) : null,
1626
+ props.enableSolutionsButton && !showSolutions ? /* @__PURE__ */ jsx6("button", { type: "button", onClick: () => setShowSolutions(true), children: "Show solution" }) : null
1627
+ ] });
1628
+ }
1629
+ var MarkTheWordsInnerForwarded = forwardRef2(MarkTheWordsInner);
1630
+ var MarkTheWords = forwardRef2(function MarkTheWords2(props, ref) {
1631
+ return /* @__PURE__ */ jsx6(AssessmentLessonGuard, { blockLabel: "MarkTheWords", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ jsx6(MarkTheWordsInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
1632
+ });
1633
+
1634
+ // src/blocks/FillInTheBlanks.tsx
1635
+ import React7, { forwardRef as forwardRef3, useEffect as useEffect6, useImperativeHandle as useImperativeHandle3, useMemo as useMemo8, useRef as useRef6, useState as useState5 } from "react";
1636
+ import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
1637
+ var INTERACTION3 = "fillInBlanks";
1638
+ function parseTemplate(template) {
1639
+ const parts = [];
1640
+ const blanks = [];
1641
+ const re = /\*([^*]+)\*/g;
1642
+ let last = 0;
1643
+ let match;
1644
+ let n = 0;
1645
+ while ((match = re.exec(template)) !== null) {
1646
+ parts.push(template.slice(last, match.index));
1647
+ const id = `blank-${n++}`;
1648
+ blanks.push({ id, answer: match[1].trim() });
1649
+ parts.push(id);
1650
+ last = match.index + match[0].length;
1651
+ }
1652
+ parts.push(template.slice(last));
1653
+ return { parts, blanks };
1654
+ }
1655
+ function FillInTheBlanksInner(props, ref) {
1656
+ const checkId = useMemo8(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1657
+ const assessment = useAssessmentState(props.enclosingLessonId);
1658
+ const parsed = useMemo8(() => parseTemplate(props.template), [props.template]);
1659
+ const blanks = props.blanks ?? parsed.blanks;
1660
+ const [values, setValues] = useState5(
1661
+ () => Object.fromEntries(blanks.map((b) => [b.id, ""]))
1662
+ );
1663
+ const [passed, setPassed] = useState5(false);
1664
+ const [showSolutions, setShowSolutions] = useState5(false);
1665
+ const completedRef = useRef6(false);
1666
+ const answeredRef = useRef6(false);
1667
+ const reset = () => {
1668
+ completedRef.current = false;
1669
+ answeredRef.current = false;
1670
+ setPassed(false);
1671
+ setValues(Object.fromEntries(blanks.map((b) => [b.id, ""])));
1672
+ setShowSolutions(false);
1673
+ };
1674
+ useEffect6(() => {
1675
+ reset();
1676
+ }, [checkId, props.template, blanks.map((b) => b.answer).join("\0")]);
1677
+ const hasBlanks = blanks.length > 0;
1678
+ const allFilled = hasBlanks && blanks.every((b) => (values[b.id] ?? "").trim().length > 0);
1679
+ let score = 0;
1680
+ blanks.forEach((b) => {
1681
+ if ((values[b.id] ?? "").trim().toLowerCase() === b.answer.toLowerCase()) score += 1;
1682
+ });
1683
+ const maxScore = blanks.length;
1684
+ const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
1685
+ const handle = useMemo8(() => {
1686
+ const handleMax = maxScore || 1;
1687
+ return {
1688
+ getScore: () => score,
1689
+ getMaxScore: () => handleMax,
1690
+ getAnswerGiven: () => allFilled,
1691
+ resetTask: reset,
1692
+ showSolutions: () => setShowSolutions(true),
1693
+ getXAPIData: () => ({
1694
+ checkId,
1695
+ interactionType: INTERACTION3,
1696
+ response: values,
1697
+ correct: passedThreshold,
1698
+ score,
1699
+ maxScore: handleMax
1700
+ })
1701
+ };
1702
+ }, [allFilled, blanks.length, checkId, maxScore, passedThreshold, score, values]);
1703
+ useImperativeHandle3(ref, () => handle, [handle]);
1704
+ useRegisterAssessmentHandle(checkId, handle);
1705
+ const check = () => {
1706
+ if (!hasBlanks) {
1707
+ if (isDevEnvironment4()) {
1708
+ console.warn("[lessonkit] FillInTheBlanks has no blanks in template");
1709
+ }
1710
+ return;
1711
+ }
1712
+ if (!allFilled) return;
1713
+ if (!answeredRef.current) {
1714
+ answeredRef.current = true;
1715
+ assessment.answer({
1716
+ checkId,
1717
+ interactionType: INTERACTION3,
1718
+ question: props.template,
1719
+ response: values,
1720
+ correct: passedThreshold
1721
+ });
1722
+ }
1723
+ if (passedThreshold && !completedRef.current) {
1724
+ completedRef.current = true;
1725
+ setPassed(true);
1726
+ assessment.complete({
1727
+ checkId,
1728
+ interactionType: INTERACTION3,
1729
+ score,
1730
+ maxScore,
1731
+ passingScore: props.passingScore ?? maxScore
1732
+ });
1733
+ }
1734
+ };
1735
+ useEffect6(() => {
1736
+ if (!allFilled) answeredRef.current = false;
1737
+ }, [allFilled]);
1738
+ useEffect6(() => {
1739
+ if (props.autoCheck && allFilled) check();
1740
+ }, [allFilled, props.autoCheck, values, passedThreshold]);
1741
+ const reveal = showSolutions || passed && props.enableSolutionsButton;
1742
+ return /* @__PURE__ */ jsxs5("section", { "aria-label": "Fill in the Blanks", "data-lk-check-id": checkId, children: [
1743
+ /* @__PURE__ */ jsx7("p", { children: parsed.parts.map((part, i) => {
1744
+ const blank = blanks.find((b) => b.id === part);
1745
+ if (!blank) return /* @__PURE__ */ jsx7(React7.Fragment, { children: part }, i);
1746
+ return /* @__PURE__ */ jsxs5("label", { style: { margin: "0 0.25em" }, children: [
1747
+ /* @__PURE__ */ jsx7("span", { className: "lk-visually-hidden", children: blank.answer }),
1748
+ /* @__PURE__ */ jsx7(
1749
+ "input",
1750
+ {
1751
+ type: "text",
1752
+ "data-testid": `blank-${blank.id}`,
1753
+ "aria-label": `Blank ${blank.id}`,
1754
+ value: reveal ? blank.answer : values[blank.id] ?? "",
1755
+ readOnly: reveal,
1756
+ disabled: passed && !props.enableRetry,
1757
+ onChange: (e) => setValues((v) => ({ ...v, [blank.id]: e.target.value })),
1758
+ onBlur: () => props.autoCheck && check(),
1759
+ size: Math.max(8, blank.answer.length + 2)
1760
+ }
1761
+ )
1762
+ ] }, blank.id);
1763
+ }) }),
1764
+ !props.autoCheck ? /* @__PURE__ */ jsx7("button", { type: "button", "data-testid": "check-blanks", disabled: !allFilled || passed, onClick: check, children: "Check" }) : null,
1765
+ !hasBlanks ? /* @__PURE__ */ jsx7("p", { role: "alert", children: "This activity has no blanks. Add text wrapped in asterisks, e.g. The *answer* here." }) : null,
1766
+ allFilled ? /* @__PURE__ */ jsx7("p", { role: "status", "aria-live": "polite", children: passed || passedThreshold ? "Correct" : "Try again" }) : null,
1767
+ props.enableRetry && passed ? /* @__PURE__ */ jsx7("button", { type: "button", onClick: reset, children: "Try again" }) : null,
1768
+ props.enableSolutionsButton && !reveal ? /* @__PURE__ */ jsx7("button", { type: "button", onClick: () => setShowSolutions(true), children: "Show solution" }) : null
1769
+ ] });
1770
+ }
1771
+ var FillInTheBlanksInnerForwarded = forwardRef3(FillInTheBlanksInner);
1772
+ var FillInTheBlanks = forwardRef3(
1773
+ function FillInTheBlanks2(props, ref) {
1774
+ return /* @__PURE__ */ jsx7(AssessmentLessonGuard, { blockLabel: "FillInTheBlanks", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ jsx7(FillInTheBlanksInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
1775
+ }
1776
+ );
1777
+
1778
+ // src/blocks/DragTheWords.tsx
1779
+ import React8, { forwardRef as forwardRef4, useEffect as useEffect7, useImperativeHandle as useImperativeHandle4, useMemo as useMemo9, useRef as useRef7, useState as useState6 } from "react";
1780
+ import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
1781
+ var INTERACTION4 = "dragTheWords";
1782
+ function parseZones(template) {
1783
+ const parts = [];
1784
+ const answers = [];
1785
+ const re = /\*([^*]+)\*/g;
1786
+ let last = 0;
1787
+ let match;
1788
+ let n = 0;
1789
+ while ((match = re.exec(template)) !== null) {
1790
+ parts.push(template.slice(last, match.index));
1791
+ answers.push(match[1].trim());
1792
+ parts.push(`zone-${n++}`);
1793
+ last = match.index + match[0].length;
1794
+ }
1795
+ parts.push(template.slice(last));
1796
+ return { parts, answers };
1797
+ }
1798
+ function DragTheWordsInner(props, ref) {
1799
+ const checkId = useMemo9(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1800
+ const assessment = useAssessmentState(props.enclosingLessonId);
1801
+ const { parts, answers } = useMemo9(() => parseZones(props.template), [props.template]);
1802
+ const [zones, setZones] = useState6(
1803
+ () => Object.fromEntries(answers.map((_, i) => [`zone-${i}`, ""]))
1804
+ );
1805
+ const [pool, setPool] = useState6(() => [...props.words]);
1806
+ const [keyboardWord, setKeyboardWord] = useState6(null);
1807
+ const [passed, setPassed] = useState6(false);
1808
+ const completedRef = useRef7(false);
1809
+ const answeredRef = useRef7(false);
1810
+ const reset = () => {
1811
+ completedRef.current = false;
1812
+ answeredRef.current = false;
1813
+ setPassed(false);
1814
+ setZones(Object.fromEntries(answers.map((_, i) => [`zone-${i}`, ""])));
1815
+ setPool([...props.words]);
1816
+ setKeyboardWord(null);
1817
+ };
1818
+ useEffect7(() => {
1819
+ reset();
1820
+ }, [checkId, props.template, props.words.join("\0")]);
1821
+ const hasZones = answers.length > 0;
1822
+ const allFilled = hasZones && answers.every((_, i) => (zones[`zone-${i}`] ?? "").length > 0);
1823
+ let score = 0;
1824
+ answers.forEach((ans, i) => {
1825
+ if ((zones[`zone-${i}`] ?? "").trim().toLowerCase() === ans.toLowerCase()) score += 1;
1826
+ });
1827
+ const maxScore = answers.length;
1828
+ const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
1829
+ const handle = useMemo9(() => {
1830
+ const handleMax = maxScore || 1;
1831
+ return {
1832
+ getScore: () => score,
1833
+ getMaxScore: () => handleMax,
1834
+ getAnswerGiven: () => allFilled,
1835
+ resetTask: reset,
1836
+ showSolutions: () => {
1837
+ },
1838
+ getXAPIData: () => ({
1839
+ checkId,
1840
+ interactionType: INTERACTION4,
1841
+ response: zones,
1842
+ correct: passedThreshold,
1843
+ score,
1844
+ maxScore: handleMax
1845
+ })
1846
+ };
1847
+ }, [allFilled, answers.length, checkId, maxScore, passedThreshold, score, zones]);
1848
+ useImperativeHandle4(ref, () => handle, [handle]);
1849
+ useRegisterAssessmentHandle(checkId, handle);
1850
+ const placeInZone = (zoneId, word) => {
1851
+ if (passed && !props.enableRetry) return;
1852
+ const prev = zones[zoneId];
1853
+ setZones((z) => ({ ...z, [zoneId]: word }));
1854
+ setPool((p) => {
1855
+ const next = p.filter((w) => w !== word);
1856
+ if (prev) next.push(prev);
1857
+ return next;
1858
+ });
1859
+ setKeyboardWord(null);
1860
+ };
1861
+ const onDragStart = (word) => (e) => {
1862
+ e.dataTransfer.setData("text/plain", word);
1863
+ };
1864
+ const onDrop = (zoneId) => (e) => {
1865
+ e.preventDefault();
1866
+ const word = e.dataTransfer.getData("text/plain");
1867
+ if (word) placeInZone(zoneId, word);
1868
+ };
1869
+ const check = () => {
1870
+ if (!hasZones) {
1871
+ if (isDevEnvironment4()) {
1872
+ console.warn("[lessonkit] DragTheWords has no drop zones in template");
1873
+ }
1874
+ return;
1875
+ }
1876
+ if (!allFilled) return;
1877
+ if (!answeredRef.current) {
1878
+ answeredRef.current = true;
1879
+ assessment.answer({
1880
+ checkId,
1881
+ interactionType: INTERACTION4,
1882
+ question: props.template,
1883
+ response: zones,
1884
+ correct: passedThreshold
1885
+ });
1886
+ }
1887
+ if (passedThreshold && !completedRef.current) {
1888
+ completedRef.current = true;
1889
+ setPassed(true);
1890
+ assessment.complete({
1891
+ checkId,
1892
+ interactionType: INTERACTION4,
1893
+ score,
1894
+ maxScore,
1895
+ passingScore: props.passingScore ?? maxScore
1896
+ });
1897
+ }
1898
+ };
1899
+ useEffect7(() => {
1900
+ if (!allFilled) answeredRef.current = false;
1901
+ }, [allFilled]);
1902
+ useEffect7(() => {
1903
+ if (props.autoCheck && allFilled) check();
1904
+ }, [allFilled, props.autoCheck, zones, passedThreshold]);
1905
+ return /* @__PURE__ */ jsxs6("section", { "aria-label": "Drag the Words", "data-lk-check-id": checkId, children: [
1906
+ /* @__PURE__ */ jsx8("p", { children: "Drag words into the blanks (or select a word, then activate a blank)." }),
1907
+ /* @__PURE__ */ jsx8("div", { role: "list", "aria-label": "Word bank", "data-testid": "word-bank", children: pool.map((word) => /* @__PURE__ */ jsx8(
1908
+ "button",
1909
+ {
1910
+ type: "button",
1911
+ draggable: true,
1912
+ "data-testid": `word-${word}`,
1913
+ "aria-pressed": keyboardWord === word,
1914
+ onDragStart: onDragStart(word),
1915
+ onClick: () => setKeyboardWord(keyboardWord === word ? null : word),
1916
+ style: { margin: "0.25rem" },
1917
+ children: word
1918
+ },
1919
+ word
1920
+ )) }),
1921
+ /* @__PURE__ */ jsx8("p", { children: parts.map((part, i) => {
1922
+ if (!part.startsWith("zone-")) return /* @__PURE__ */ jsx8(React8.Fragment, { children: part }, i);
1923
+ return /* @__PURE__ */ jsx8(
1924
+ "span",
1925
+ {
1926
+ role: "button",
1927
+ tabIndex: 0,
1928
+ "data-testid": part,
1929
+ onDragOver: (e) => e.preventDefault(),
1930
+ onDrop: onDrop(part),
1931
+ onClick: () => keyboardWord && placeInZone(part, keyboardWord),
1932
+ onKeyDown: (e) => {
1933
+ if (e.key === "Enter" && keyboardWord) placeInZone(part, keyboardWord);
1934
+ },
1935
+ style: {
1936
+ display: "inline-block",
1937
+ minWidth: "6em",
1938
+ border: "1px dashed currentColor",
1939
+ padding: "0.2em 0.5em",
1940
+ margin: "0 0.2em"
1941
+ },
1942
+ children: zones[part] || "___"
1943
+ },
1944
+ part
1945
+ );
1946
+ }) }),
1947
+ /* @__PURE__ */ jsx8("button", { type: "button", "data-testid": "check-drag-words", disabled: !allFilled || passed, onClick: check, children: "Check" }),
1948
+ !hasZones ? /* @__PURE__ */ jsx8("p", { role: "alert", children: "This activity has no drop zones. Wrap answers in asterisks in the template." }) : null,
1949
+ allFilled ? /* @__PURE__ */ jsx8("p", { role: "status", "aria-live": "polite", children: passed || passedThreshold ? "Correct" : "Try again" }) : null
1950
+ ] });
1951
+ }
1952
+ var DragTheWordsInnerForwarded = forwardRef4(DragTheWordsInner);
1953
+ var DragTheWords = forwardRef4(function DragTheWords2(props, ref) {
1954
+ return /* @__PURE__ */ jsx8(AssessmentLessonGuard, { blockLabel: "DragTheWords", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ jsx8(DragTheWordsInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
1955
+ });
1956
+
1957
+ // src/blocks/DragAndDrop.tsx
1958
+ import { forwardRef as forwardRef5, useEffect as useEffect8, useImperativeHandle as useImperativeHandle5, useMemo as useMemo10, useRef as useRef8, useState as useState7 } from "react";
1959
+ import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
1960
+ var INTERACTION5 = "dragAndDrop";
1961
+ function DragAndDropInner(props, ref) {
1962
+ const checkId = useMemo10(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1963
+ const assessment = useAssessmentState(props.enclosingLessonId);
1964
+ const [assignments, setAssignments] = useState7(
1965
+ () => Object.fromEntries(props.targets.map((t) => [t.id, ""]))
1966
+ );
1967
+ const [pool, setPool] = useState7(() => props.items.map((i) => i.id));
1968
+ const [keyboardItem, setKeyboardItem] = useState7(null);
1969
+ const [passed, setPassed] = useState7(false);
1970
+ const completedRef = useRef8(false);
1971
+ const reset = () => {
1972
+ completedRef.current = false;
1973
+ setPassed(false);
1974
+ setAssignments(Object.fromEntries(props.targets.map((t) => [t.id, ""])));
1975
+ setPool(props.items.map((i) => i.id));
1976
+ setKeyboardItem(null);
1977
+ };
1978
+ useEffect8(() => {
1979
+ reset();
1980
+ }, [checkId, props.items.map((i) => i.id).join(","), props.targets.map((t) => t.id).join(",")]);
1981
+ const allFilled = props.targets.every((t) => (assignments[t.id] ?? "").length > 0);
1982
+ const allCorrect = props.targets.every((t) => assignments[t.id] === t.accepts);
1983
+ const handle = useMemo10(() => {
1984
+ const maxScore = props.targets.length || 1;
1985
+ let score = 0;
1986
+ props.targets.forEach((t) => {
1987
+ if (assignments[t.id] === t.accepts) score += 1;
1988
+ });
1989
+ return {
1990
+ getScore: () => score,
1991
+ getMaxScore: () => maxScore,
1992
+ getAnswerGiven: () => allFilled,
1993
+ resetTask: reset,
1994
+ showSolutions: () => {
1995
+ },
1996
+ getXAPIData: () => ({
1997
+ checkId,
1998
+ interactionType: INTERACTION5,
1999
+ response: assignments,
2000
+ correct: allCorrect,
2001
+ score,
2002
+ maxScore
2003
+ })
2004
+ };
2005
+ }, [allCorrect, allFilled, assignments, checkId, props.targets]);
2006
+ useImperativeHandle5(ref, () => handle, [handle]);
2007
+ useRegisterAssessmentHandle(checkId, handle);
2008
+ const place = (targetId, itemId) => {
2009
+ if (passed && !props.enableRetry) return;
2010
+ const prev = assignments[targetId];
2011
+ setAssignments((a) => ({ ...a, [targetId]: itemId }));
2012
+ setPool((p) => {
2013
+ const next = p.filter((id) => id !== itemId);
2014
+ if (prev) next.push(prev);
2015
+ return next;
2016
+ });
2017
+ setKeyboardItem(null);
2018
+ };
2019
+ const check = () => {
2020
+ if (!allFilled) return;
2021
+ assessment.answer({
2022
+ checkId,
2023
+ interactionType: INTERACTION5,
2024
+ response: assignments,
2025
+ correct: allCorrect
2026
+ });
2027
+ if (allCorrect && !completedRef.current) {
2028
+ completedRef.current = true;
2029
+ setPassed(true);
2030
+ assessment.complete({
2031
+ checkId,
2032
+ interactionType: INTERACTION5,
2033
+ score: props.targets.length,
2034
+ maxScore: props.targets.length,
2035
+ passingScore: props.passingScore ?? props.targets.length
2036
+ });
2037
+ }
2038
+ };
2039
+ return /* @__PURE__ */ jsxs7("section", { "aria-label": "Drag and Drop", "data-lk-check-id": checkId, children: [
2040
+ /* @__PURE__ */ jsx9("p", { children: "Match each item to the correct target (drag or use keyboard: select item, then activate target)." }),
2041
+ /* @__PURE__ */ jsx9("div", { role: "list", "aria-label": "Draggable items", children: pool.map((id) => {
2042
+ const item = props.items.find((i) => i.id === id);
2043
+ return /* @__PURE__ */ jsx9(
2044
+ "button",
2045
+ {
2046
+ type: "button",
2047
+ draggable: true,
2048
+ "data-testid": `drag-item-${id}`,
2049
+ "aria-pressed": keyboardItem === id,
2050
+ onDragStart: (e) => e.dataTransfer.setData("text/plain", id),
2051
+ onClick: () => setKeyboardItem(keyboardItem === id ? null : id),
2052
+ style: { margin: "0.25rem" },
2053
+ children: item.label
2054
+ },
2055
+ id
2056
+ );
2057
+ }) }),
2058
+ /* @__PURE__ */ jsx9("ul", { children: props.targets.map((target) => {
2059
+ const assigned = assignments[target.id];
2060
+ const label = assigned ? props.items.find((i) => i.id === assigned)?.label ?? assigned : "Drop here";
2061
+ return /* @__PURE__ */ jsxs7("li", { children: [
2062
+ /* @__PURE__ */ jsx9("strong", { children: target.label }),
2063
+ " ",
2064
+ /* @__PURE__ */ jsx9(
2065
+ "span",
2066
+ {
2067
+ role: "button",
2068
+ tabIndex: 0,
2069
+ "data-testid": `drop-${target.id}`,
2070
+ onDragOver: (e) => e.preventDefault(),
2071
+ onDrop: (e) => {
2072
+ e.preventDefault();
2073
+ const id = e.dataTransfer.getData("text/plain");
2074
+ if (id) place(target.id, id);
2075
+ },
2076
+ onClick: () => keyboardItem && place(target.id, keyboardItem),
2077
+ onKeyDown: (e) => {
2078
+ if (e.key === "Enter" && keyboardItem) place(target.id, keyboardItem);
2079
+ },
2080
+ style: {
2081
+ display: "inline-block",
2082
+ minWidth: "8em",
2083
+ border: "1px dashed currentColor",
2084
+ padding: "0.25em"
2085
+ },
2086
+ children: label
2087
+ }
2088
+ )
2089
+ ] }, target.id);
2090
+ }) }),
2091
+ /* @__PURE__ */ jsx9("button", { type: "button", "data-testid": "check-drag-drop", disabled: !allFilled || passed, onClick: check, children: "Check" }),
2092
+ allFilled ? /* @__PURE__ */ jsx9("p", { role: "status", "aria-live": "polite", children: passed || allCorrect ? "Correct" : "Try again" }) : null
2093
+ ] });
2094
+ }
2095
+ var DragAndDropInnerForwarded = forwardRef5(DragAndDropInner);
2096
+ var DragAndDrop = forwardRef5(function DragAndDrop2(props, ref) {
2097
+ return /* @__PURE__ */ jsx9(AssessmentLessonGuard, { blockLabel: "DragAndDrop", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ jsx9(DragAndDropInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
2098
+ });
2099
+
2100
+ // src/blocks/AssessmentSequence.tsx
2101
+ import React10, { useCallback as useCallback3, useMemo as useMemo11, useState as useState8 } from "react";
2102
+ import { jsx as jsx10, jsxs as jsxs8 } from "react/jsx-runtime";
2103
+ function AssessmentSequence(props) {
2104
+ const sequential = props.sequential !== false;
2105
+ const childArray = React10.Children.toArray(props.children).filter(React10.isValidElement);
2106
+ const [index, setIndex] = useState8(0);
2107
+ const current = childArray[index] ?? null;
2108
+ const goNext = useCallback3(() => {
2109
+ setIndex((i) => Math.min(i + 1, childArray.length - 1));
2110
+ }, [childArray.length]);
2111
+ const goPrev = useCallback3(() => {
2112
+ setIndex((i) => Math.max(i - 1, 0));
2113
+ }, []);
2114
+ const progress = useMemo11(
2115
+ () => ({ current: index + 1, total: childArray.length }),
2116
+ [index, childArray.length]
2117
+ );
2118
+ if (!sequential) {
2119
+ return /* @__PURE__ */ jsx10(AssessmentSequenceProvider, { children: /* @__PURE__ */ jsx10("section", { "aria-label": "Assessment sequence", children: props.children }) });
2120
+ }
2121
+ return /* @__PURE__ */ jsx10(AssessmentSequenceProvider, { children: /* @__PURE__ */ jsxs8("section", { "aria-label": "Assessment sequence", "data-testid": "assessment-sequence", children: [
2122
+ /* @__PURE__ */ jsxs8("p", { children: [
2123
+ "Question ",
2124
+ progress.current,
2125
+ " of ",
2126
+ progress.total
2127
+ ] }),
2128
+ /* @__PURE__ */ jsx10("div", { "data-testid": "assessment-sequence-step", children: current }),
2129
+ /* @__PURE__ */ jsxs8("nav", { "aria-label": "Sequence navigation", children: [
2130
+ /* @__PURE__ */ jsx10("button", { type: "button", "data-testid": "sequence-prev", disabled: index === 0, onClick: goPrev, children: "Previous" }),
2131
+ /* @__PURE__ */ jsx10(
2132
+ "button",
2133
+ {
2134
+ type: "button",
2135
+ "data-testid": "sequence-next",
2136
+ disabled: index >= childArray.length - 1,
2137
+ onClick: goNext,
2138
+ children: "Next"
2139
+ }
2140
+ )
2141
+ ] })
2142
+ ] }) });
2143
+ }
2144
+
1158
2145
  // src/index.tsx
1159
2146
  import {
1160
2147
  buildTelemetryEvent as buildTelemetryEvent2,
@@ -1167,14 +2154,14 @@ import {
1167
2154
  } from "@lessonkit/core";
1168
2155
 
1169
2156
  // src/theme/ThemeProvider.tsx
1170
- import React3, {
1171
- createContext as createContext3,
1172
- useCallback as useCallback2,
1173
- useContext as useContext3,
2157
+ import React11, {
2158
+ createContext as createContext4,
2159
+ useCallback as useCallback4,
2160
+ useContext as useContext4,
1174
2161
  useLayoutEffect as useLayoutEffect2,
1175
- useMemo as useMemo4,
1176
- useRef as useRef3,
1177
- useState as useState3
2162
+ useMemo as useMemo12,
2163
+ useRef as useRef9,
2164
+ useState as useState9
1178
2165
  } from "react";
1179
2166
  import {
1180
2167
  brandThemeOverrides,
@@ -1201,9 +2188,12 @@ function applyCssVariables(target, vars, previousKeys) {
1201
2188
  }
1202
2189
 
1203
2190
  // src/theme/ThemeProvider.tsx
1204
- import { jsx as jsx3 } from "react/jsx-runtime";
1205
- var ThemeContext = createContext3(null);
1206
- var useIsoLayoutEffect2 = typeof window !== "undefined" ? useLayoutEffect2 : React3.useEffect;
2191
+ import { jsx as jsx11 } from "react/jsx-runtime";
2192
+ var ThemeContext = createContext4(null);
2193
+ var useIsoLayoutEffect2 = (
2194
+ /* v8 ignore next -- SSR uses useEffect when window is unavailable */
2195
+ typeof window !== "undefined" ? useLayoutEffect2 : React11.useEffect
2196
+ );
1207
2197
  function getSystemMode() {
1208
2198
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
1209
2199
  return "light";
@@ -1221,7 +2211,7 @@ function ThemeProvider(props) {
1221
2211
  const preset = props.preset ?? "default";
1222
2212
  const mode = props.mode ?? "light";
1223
2213
  const targetKind = props.target ?? "document";
1224
- const [resolvedMode, setResolvedMode] = useState3(
2214
+ const [resolvedMode, setResolvedMode] = useState9(
1225
2215
  () => mode === "system" ? getSystemMode() : mode
1226
2216
  );
1227
2217
  useIsoLayoutEffect2(() => {
@@ -1237,20 +2227,20 @@ function ThemeProvider(props) {
1237
2227
  return () => mq.removeEventListener("change", onChange);
1238
2228
  }, [mode]);
1239
2229
  const dataTheme = mode === "system" ? resolvedMode : mode === "dark" ? "dark" : "light";
1240
- const effectiveTheme = useMemo4(() => {
2230
+ const effectiveTheme = useMemo12(() => {
1241
2231
  const modeBase = resolveModeBase(mode, dataTheme);
1242
2232
  const base = preset === "default" ? modeBase : preset === "brand" ? mergeThemes(modeBase, brandThemeOverrides) : mergeThemes(modeBase, getPresetTheme(preset));
1243
2233
  return mergeThemes(base, props.theme ?? {});
1244
2234
  }, [preset, mode, dataTheme, props.theme]);
1245
- const hostRef = useRef3(null);
1246
- const appliedKeysRef = useRef3(/* @__PURE__ */ new Set());
2235
+ const hostRef = useRef9(null);
2236
+ const appliedKeysRef = useRef9(/* @__PURE__ */ new Set());
1247
2237
  useIsoLayoutEffect2(() => {
1248
2238
  if (targetKind === "document" && typeof document !== "undefined") {
1249
2239
  document.documentElement.setAttribute("data-lk-theme", dataTheme);
1250
2240
  return () => document.documentElement.removeAttribute("data-lk-theme");
1251
2241
  }
1252
2242
  }, [targetKind, dataTheme]);
1253
- const inject = useCallback2(() => {
2243
+ const inject = useCallback4(() => {
1254
2244
  const vars = themeToCssVariables(effectiveTheme);
1255
2245
  const el = targetKind === "document" && typeof document !== "undefined" ? document.documentElement : hostRef.current;
1256
2246
  if (!el) return;
@@ -1267,7 +2257,7 @@ function ThemeProvider(props) {
1267
2257
  appliedKeysRef.current = /* @__PURE__ */ new Set();
1268
2258
  };
1269
2259
  }, [inject, targetKind]);
1270
- const value = useMemo4(
2260
+ const value = useMemo12(
1271
2261
  () => ({
1272
2262
  theme: effectiveTheme,
1273
2263
  preset,
@@ -1277,12 +2267,12 @@ function ThemeProvider(props) {
1277
2267
  [effectiveTheme, preset, mode, dataTheme]
1278
2268
  );
1279
2269
  if (targetKind === "document") {
1280
- return /* @__PURE__ */ jsx3(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx3("div", { "data-lk-theme": dataTheme, style: { display: "contents" }, children: props.children }) });
2270
+ return /* @__PURE__ */ jsx11(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx11("div", { "data-lk-theme": dataTheme, style: { display: "contents" }, children: props.children }) });
1281
2271
  }
1282
- return /* @__PURE__ */ jsx3(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx3("div", { ref: hostRef, "data-lk-theme": dataTheme, children: props.children }) });
2272
+ return /* @__PURE__ */ jsx11(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx11("div", { ref: hostRef, "data-lk-theme": dataTheme, children: props.children }) });
1283
2273
  }
1284
2274
  function useTheme() {
1285
- const ctx = useContext3(ThemeContext);
2275
+ const ctx = useContext4(ThemeContext);
1286
2276
  if (!ctx) {
1287
2277
  throw new Error("useTheme must be used within a ThemeProvider");
1288
2278
  }
@@ -1291,6 +2281,7 @@ function useTheme() {
1291
2281
 
1292
2282
  // src/blockCatalog.ts
1293
2283
  var blockCatalogVersion = 1;
2284
+ var blockCatalogV2Version = 2;
1294
2285
  var BLOCK_CATALOG = [
1295
2286
  {
1296
2287
  type: "Course",
@@ -1477,8 +2468,163 @@ var BLOCK_CATALOG = [
1477
2468
  }
1478
2469
  }
1479
2470
  ];
1480
- function buildBlockCatalog() {
1481
- return BLOCK_CATALOG.map((entry) => ({
2471
+ var assessmentBehaviourProps = [
2472
+ { name: "enableRetry", type: "boolean", required: false, description: "Allow retry after completion." },
2473
+ { name: "enableSolutionsButton", type: "boolean", required: false, description: "Show solution control." },
2474
+ { name: "autoCheck", type: "boolean", required: false, description: "Check answers automatically when possible." },
2475
+ { name: "passingScore", type: "number", required: false, description: "Minimum score to pass." }
2476
+ ];
2477
+ var v2AssessmentEntries = [
2478
+ {
2479
+ type: "TrueFalse",
2480
+ category: "assessment",
2481
+ assessmentContract: true,
2482
+ h5pMachineName: "H5P.TrueFalse",
2483
+ h5pAlias: "True/False",
2484
+ description: "Binary true/false question with assessment contract.",
2485
+ props: [
2486
+ { name: "checkId", type: "CheckId", required: true, description: "Stable check id." },
2487
+ { name: "question", type: "string", required: true, description: "Question text." },
2488
+ { name: "answer", type: "boolean", required: true, description: "Correct answer." },
2489
+ ...assessmentBehaviourProps
2490
+ ],
2491
+ requiredIds: ["checkId"],
2492
+ parentConstraints: ["Lesson", "AssessmentSequence"],
2493
+ a11y: {
2494
+ element: "section",
2495
+ ariaLabel: "True or False",
2496
+ keyboard: "Radio group with True/False options.",
2497
+ liveRegions: "role='status' for feedback.",
2498
+ notes: "H5P True/False equivalent."
2499
+ },
2500
+ theming: { surface: "global-inherit", dataAttributes: ["data-lk-check-id"], stylingNotes: "Uses data-lk-check-id." },
2501
+ telemetry: { emits: ["assessment_answered", "assessment_completed"], requiresActiveLesson: true }
2502
+ },
2503
+ {
2504
+ type: "FillInTheBlanks",
2505
+ category: "assessment",
2506
+ assessmentContract: true,
2507
+ h5pMachineName: "H5P.Blanks",
2508
+ h5pAlias: "Fill in the Blanks",
2509
+ description: "Fill-in-the-blank text with *answer* markers in template.",
2510
+ props: [
2511
+ { name: "checkId", type: "CheckId", required: true, description: "Stable check id." },
2512
+ { name: "template", type: "string", required: true, description: "Text with *blank* markers." },
2513
+ { name: "blanks", type: "FillInBlankSpec[]", required: false, description: "Explicit blank specs." },
2514
+ ...assessmentBehaviourProps
2515
+ ],
2516
+ requiredIds: ["checkId"],
2517
+ parentConstraints: ["Lesson", "AssessmentSequence"],
2518
+ a11y: {
2519
+ element: "section",
2520
+ ariaLabel: "Fill in the Blanks",
2521
+ keyboard: "Tab between text inputs.",
2522
+ notes: "H5P Fill in the Blanks equivalent."
2523
+ },
2524
+ theming: { surface: "global-inherit", dataAttributes: ["data-lk-check-id"], stylingNotes: "Uses data-lk-check-id." },
2525
+ telemetry: { emits: ["assessment_answered", "assessment_completed"], requiresActiveLesson: true }
2526
+ },
2527
+ {
2528
+ type: "DragAndDrop",
2529
+ category: "assessment",
2530
+ assessmentContract: true,
2531
+ h5pMachineName: "H5P.DragQuestion",
2532
+ h5pAlias: "Drag and Drop",
2533
+ description: "Drag items onto labeled targets.",
2534
+ props: [
2535
+ { name: "checkId", type: "CheckId", required: true, description: "Stable check id." },
2536
+ { name: "items", type: "DragItem[]", required: true, description: "Draggable items." },
2537
+ { name: "targets", type: "DropTarget[]", required: true, description: "Drop targets." },
2538
+ ...assessmentBehaviourProps
2539
+ ],
2540
+ requiredIds: ["checkId"],
2541
+ parentConstraints: ["Lesson", "AssessmentSequence"],
2542
+ a11y: {
2543
+ element: "section",
2544
+ ariaLabel: "Drag and Drop",
2545
+ keyboard: "Select item then activate target; drag also supported.",
2546
+ notes: "H5P Drag and Drop equivalent."
2547
+ },
2548
+ theming: { surface: "global-inherit", dataAttributes: ["data-lk-check-id"], stylingNotes: "Uses data-lk-check-id." },
2549
+ telemetry: { emits: ["assessment_answered", "assessment_completed"], requiresActiveLesson: true }
2550
+ },
2551
+ {
2552
+ type: "DragTheWords",
2553
+ category: "assessment",
2554
+ assessmentContract: true,
2555
+ h5pMachineName: "H5P.DragText",
2556
+ h5pAlias: "Drag the Words",
2557
+ description: "Drag words into inline blanks.",
2558
+ props: [
2559
+ { name: "checkId", type: "CheckId", required: true, description: "Stable check id." },
2560
+ { name: "template", type: "string", required: true, description: "Sentence with *blank* zones." },
2561
+ { name: "words", type: "string[]", required: true, description: "Draggable word bank." },
2562
+ ...assessmentBehaviourProps
2563
+ ],
2564
+ requiredIds: ["checkId"],
2565
+ parentConstraints: ["Lesson", "AssessmentSequence"],
2566
+ a11y: {
2567
+ element: "section",
2568
+ ariaLabel: "Drag the Words",
2569
+ keyboard: "Select word then activate zone.",
2570
+ notes: "H5P Drag the Words equivalent."
2571
+ },
2572
+ theming: { surface: "global-inherit", dataAttributes: ["data-lk-check-id"], stylingNotes: "Uses data-lk-check-id." },
2573
+ telemetry: { emits: ["assessment_answered", "assessment_completed"], requiresActiveLesson: true }
2574
+ },
2575
+ {
2576
+ type: "MarkTheWords",
2577
+ category: "assessment",
2578
+ assessmentContract: true,
2579
+ h5pMachineName: "H5P.MarkTheWords",
2580
+ h5pAlias: "Mark the Words",
2581
+ description: "Select correct words in a sentence.",
2582
+ props: [
2583
+ { name: "checkId", type: "CheckId", required: true, description: "Stable check id." },
2584
+ { name: "text", type: "string", required: true, description: "Source text." },
2585
+ { name: "correctWords", type: "string[]", required: true, description: "Words to mark." },
2586
+ ...assessmentBehaviourProps
2587
+ ],
2588
+ requiredIds: ["checkId"],
2589
+ parentConstraints: ["Lesson", "AssessmentSequence"],
2590
+ a11y: {
2591
+ element: "section",
2592
+ ariaLabel: "Mark the Words",
2593
+ keyboard: "Toggle words with buttons.",
2594
+ notes: "H5P Mark the Words equivalent."
2595
+ },
2596
+ theming: { surface: "global-inherit", dataAttributes: ["data-lk-check-id"], stylingNotes: "Uses data-lk-check-id." },
2597
+ telemetry: { emits: ["assessment_answered", "assessment_completed"], requiresActiveLesson: true }
2598
+ },
2599
+ {
2600
+ type: "AssessmentSequence",
2601
+ category: "container",
2602
+ h5pMachineName: "H5P.QuestionSet",
2603
+ h5pAlias: "Question Set",
2604
+ description: "Ordered sequence of contract-compliant assessments.",
2605
+ props: [
2606
+ { name: "children", type: "ReactNode", required: true, description: "Assessment blocks." },
2607
+ { name: "sequential", type: "boolean", required: false, description: "One question at a time." },
2608
+ ...assessmentBehaviourProps.filter((p) => p.name !== "passingScore")
2609
+ ],
2610
+ requiredIds: [],
2611
+ parentConstraints: ["Lesson"],
2612
+ a11y: {
2613
+ element: "section",
2614
+ ariaLabel: "Assessment sequence",
2615
+ keyboard: "Previous/Next navigation between steps.",
2616
+ notes: "H5P Question Set equivalent."
2617
+ },
2618
+ theming: { surface: "global-inherit", stylingNotes: "Container for assessments." },
2619
+ telemetry: { emits: [], manualTracking: "Child assessments emit assessment_* events." }
2620
+ }
2621
+ ];
2622
+ var BLOCK_CATALOG_V2 = [
2623
+ ...BLOCK_CATALOG,
2624
+ ...v2AssessmentEntries
2625
+ ];
2626
+ function cloneCatalogEntry(entry) {
2627
+ return {
1482
2628
  ...entry,
1483
2629
  props: entry.props.map((p) => ({ ...p })),
1484
2630
  aliases: entry.aliases ? [...entry.aliases] : void 0,
@@ -1493,22 +2639,37 @@ function buildBlockCatalog() {
1493
2639
  ...entry.telemetry,
1494
2640
  emits: [...entry.telemetry.emits]
1495
2641
  }
1496
- }));
2642
+ };
2643
+ }
2644
+ function buildBlockCatalog(opts) {
2645
+ const version = opts?.version ?? 2;
2646
+ const source = version === 2 ? BLOCK_CATALOG_V2 : BLOCK_CATALOG;
2647
+ return source.map((entry) => cloneCatalogEntry(entry));
1497
2648
  }
1498
- function getBlockCatalogEntry(type) {
1499
- return BLOCK_CATALOG.find((entry) => entry.type === type || entry.aliases?.includes(type));
2649
+ function getBlockCatalogEntry(type, opts) {
2650
+ const version = opts?.version ?? 2;
2651
+ const source = version === 2 ? BLOCK_CATALOG_V2 : BLOCK_CATALOG;
2652
+ return source.find((entry) => entry.type === type || entry.aliases?.includes(type));
1500
2653
  }
1501
2654
  export {
2655
+ AssessmentSequence,
1502
2656
  BLOCK_CATALOG,
2657
+ BLOCK_CATALOG_V2,
1503
2658
  Course,
2659
+ DragAndDrop,
2660
+ DragTheWords,
2661
+ FillInTheBlanks,
1504
2662
  KnowledgeCheck,
1505
2663
  Lesson,
1506
2664
  LessonkitProvider,
2665
+ MarkTheWords,
1507
2666
  ProgressTracker,
1508
2667
  Quiz,
1509
2668
  Reflection,
1510
2669
  Scenario,
1511
2670
  ThemeProvider,
2671
+ TrueFalse,
2672
+ blockCatalogV2Version,
1512
2673
  blockCatalogVersion,
1513
2674
  buildBlockCatalog,
1514
2675
  buildTelemetryEvent2 as buildTelemetryEvent,
@@ -1519,7 +2680,9 @@ export {
1519
2680
  defineLifecyclePlugin,
1520
2681
  defineTelemetryPlugin,
1521
2682
  getBlockCatalogEntry,
2683
+ resetAssessmentWarningsForTests,
1522
2684
  resetQuizWarningsForTests,
2685
+ useAssessmentState,
1523
2686
  useCompletion,
1524
2687
  useLessonkit,
1525
2688
  useProgress,