@lessonkit/react 1.4.0 → 1.5.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.
@@ -33,9 +33,14 @@ __export(blocks_entry_exports, {
33
33
  Accordion: () => Accordion,
34
34
  ArithmeticQuiz: () => ArithmeticQuiz,
35
35
  AssessmentSequence: () => AssessmentSequence,
36
+ BranchChoice: () => BranchChoice,
37
+ BranchNode: () => BranchNode,
38
+ BranchingScenario: () => BranchingScenario,
39
+ Chart: () => Chart,
36
40
  DialogCards: () => DialogCards,
37
41
  DragAndDrop: () => DragAndDrop,
38
42
  DragTheWords: () => DragTheWords,
43
+ Embed: () => Embed,
39
44
  Essay: () => Essay,
40
45
  FillInTheBlanks: () => FillInTheBlanks,
41
46
  FindHotspot: () => FindHotspot,
@@ -336,9 +341,6 @@ var import_core10 = require("@lessonkit/core");
336
341
  // src/runtime/observability.ts
337
342
  var import_xapi = require("@lessonkit/xapi");
338
343
 
339
- // src/provider/useLessonkitProviderRuntime.ts
340
- var import_xapi5 = require("@lessonkit/xapi");
341
-
342
344
  // src/runtime/emitTelemetry.ts
343
345
  var import_core4 = require("@lessonkit/core");
344
346
 
@@ -349,36 +351,45 @@ var import_xapi2 = require("@lessonkit/xapi");
349
351
  // src/runtime/lxpackBridge.ts
350
352
  var import_bridge = require("@lessonkit/lxpack/bridge");
351
353
 
352
- // src/runtime/ports.ts
353
- var import_core5 = require("@lessonkit/core");
354
+ // src/runtime/courseStartedPipeline.ts
355
+ var import_xapi3 = require("@lessonkit/xapi");
354
356
 
355
- // src/provider/useLessonkitProviderRuntime.ts
356
- var import_core11 = require("@lessonkit/core");
357
+ // src/runtime/plugins.ts
358
+ var import_core5 = require("@lessonkit/core");
359
+ function buildPluginContext(opts) {
360
+ return (0, import_core5.buildPluginContext)(opts);
361
+ }
357
362
 
358
- // src/runtime/progress.ts
363
+ // src/runtime/session.ts
359
364
  var import_core6 = require("@lessonkit/core");
360
365
 
361
- // src/runtime/xapi.ts
362
- var import_xapi3 = require("@lessonkit/xapi");
366
+ // src/provider/courseStarted/emit.ts
367
+ function createCourseStartedFlightScope() {
368
+ return {
369
+ trackingFlights: /* @__PURE__ */ new Map(),
370
+ emitFlights: /* @__PURE__ */ new Map()
371
+ };
372
+ }
373
+ var defaultFlightScope = createCourseStartedFlightScope();
363
374
 
364
- // src/runtime/session.ts
375
+ // src/provider/useLessonkitProviderRuntime.ts
376
+ var import_xapi5 = require("@lessonkit/xapi");
377
+
378
+ // src/runtime/ports.ts
365
379
  var import_core7 = require("@lessonkit/core");
366
380
 
367
- // src/runtime/courseStartedPipeline.ts
368
- var import_xapi4 = require("@lessonkit/xapi");
381
+ // src/provider/useLessonkitProviderRuntime.ts
382
+ var import_core11 = require("@lessonkit/core");
369
383
 
370
- // src/runtime/plugins.ts
384
+ // src/runtime/progress.ts
371
385
  var import_core8 = require("@lessonkit/core");
372
- function buildPluginContext(opts) {
373
- return (0, import_core8.buildPluginContext)(opts);
374
- }
386
+
387
+ // src/runtime/xapi.ts
388
+ var import_xapi4 = require("@lessonkit/xapi");
375
389
 
376
390
  // src/runtime/telemetry.ts
377
391
  var import_core9 = require("@lessonkit/core");
378
392
 
379
- // src/provider/useLessonkitProviderRuntime.ts
380
- var defaultStorage = (0, import_core5.createSessionStoragePort)();
381
-
382
393
  // src/context.tsx
383
394
  var import_jsx_runtime5 = require("react/jsx-runtime");
384
395
  var LessonkitContext = (0, import_react8.createContext)(null);
@@ -418,14 +429,15 @@ function meetsPassingThreshold(score, maxScore, passingScore) {
418
429
  }
419
430
  function scoreFromCustom(custom, fallbackCorrect, fallbackMax = 1, passingScore) {
420
431
  const maxScore = custom?.maxScore ?? fallbackMax;
432
+ const hasNumericScore = custom?.score != null && Number.isFinite(custom.score);
433
+ if (hasNumericScore) {
434
+ const passed2 = custom.passed !== void 0 ? custom.passed : meetsPassingThreshold(custom.score, maxScore, passingScore);
435
+ return { score: custom.score, maxScore, passed: passed2 };
436
+ }
421
437
  if (custom?.passed !== void 0) {
422
- const score2 = custom.passed ? custom.score ?? maxScore : custom.score ?? 0;
438
+ const score2 = custom.passed ? maxScore : 0;
423
439
  return { score: score2, maxScore, passed: custom.passed };
424
440
  }
425
- if (custom?.maxScore != null && custom.maxScore > 0 && custom.score != null) {
426
- const passed2 = meetsPassingThreshold(custom.score, custom.maxScore, passingScore);
427
- return { score: custom.score, maxScore: custom.maxScore, passed: passed2 };
428
- }
429
441
  const score = fallbackCorrect ? maxScore : 0;
430
442
  const passed = meetsPassingThreshold(score, maxScore, passingScore);
431
443
  return { score, maxScore, passed };
@@ -500,7 +512,7 @@ function TrueFalseInner(props, ref) {
500
512
  if (passed) {
501
513
  return { score: completedScore ?? maxScore, maxScore };
502
514
  }
503
- if (selectionCorrect) {
515
+ if (selected !== null && selectionCorrect) {
504
516
  return { score: completedMaxScore ?? maxScore, maxScore };
505
517
  }
506
518
  return { score: 0, maxScore };
@@ -572,7 +584,9 @@ function TrueFalseInner(props, ref) {
572
584
  if (nextPassed) {
573
585
  const maxScore = nextCompletedMaxScore ?? completedMaxScore ?? 1;
574
586
  const score = nextCompletedScore ?? completedScore ?? maxScore;
575
- replayTelemetry(nextSelected ?? null, nextCorrect ?? null, nextPassed, score, maxScore);
587
+ if (config.tracking?.replayResumeEvents === true) {
588
+ replayTelemetry(nextSelected ?? null, nextCorrect ?? null, nextPassed, score, maxScore);
589
+ }
576
590
  }
577
591
  }
578
592
  readBooleanStateField(state, "showSolutions", setShowSolutions);
@@ -588,7 +602,8 @@ function TrueFalseInner(props, ref) {
588
602
  props.question,
589
603
  selected,
590
604
  selectionCorrect,
591
- showSolutions
605
+ showSolutions,
606
+ config.tracking?.replayResumeEvents
592
607
  ]
593
608
  );
594
609
  useAssessmentHandleRegistration(checkId, handle, ref);
@@ -617,6 +632,17 @@ function TrueFalseInner(props, ref) {
617
632
  maxScore: scored.maxScore,
618
633
  passingScore: props.passingScore ?? scored.maxScore
619
634
  });
635
+ } else if (!scored.passed && props.enableRetry === false && !completedRef.current) {
636
+ completedRef.current = true;
637
+ setCompletedScore(scored.score);
638
+ setCompletedMaxScore(scored.maxScore);
639
+ assessment.complete({
640
+ checkId,
641
+ interactionType: INTERACTION,
642
+ score: scored.score,
643
+ maxScore: scored.maxScore,
644
+ passingScore: props.passingScore ?? scored.maxScore
645
+ });
620
646
  }
621
647
  };
622
648
  const reveal = showSolutions || passed && props.enableSolutionsButton;
@@ -682,11 +708,13 @@ function MarkTheWordsInner(props, ref) {
682
708
  );
683
709
  const [marked, setMarked] = (0, import_react13.useState)(() => /* @__PURE__ */ new Set());
684
710
  const [passed, setPassed] = (0, import_react13.useState)(false);
711
+ const [submitted, setSubmitted] = (0, import_react13.useState)(false);
685
712
  const [showSolutions, setShowSolutions] = (0, import_react13.useState)(false);
686
713
  const completedRef = (0, import_react13.useRef)(false);
687
714
  const reset = () => {
688
715
  completedRef.current = false;
689
716
  setPassed(false);
717
+ setSubmitted(false);
690
718
  setMarked(/* @__PURE__ */ new Set());
691
719
  setShowSolutions(false);
692
720
  };
@@ -744,6 +772,27 @@ function MarkTheWordsInner(props, ref) {
744
772
  return next;
745
773
  });
746
774
  };
775
+ const submitMarks = () => {
776
+ if (!hasTargets || completedRef.current || marked.size === 0) return;
777
+ completedRef.current = true;
778
+ setSubmitted(true);
779
+ const didPass = passedThreshold;
780
+ if (didPass) setPassed(true);
781
+ assessment.answer({
782
+ checkId,
783
+ interactionType: INTERACTION2,
784
+ question: props.text,
785
+ response: [...marked].map((i) => tokens[i]),
786
+ correct: didPass
787
+ });
788
+ assessment.complete({
789
+ checkId,
790
+ interactionType: INTERACTION2,
791
+ score,
792
+ maxScore,
793
+ passingScore: props.passingScore ?? maxScore
794
+ });
795
+ };
747
796
  (0, import_react13.useEffect)(() => {
748
797
  if (!hasTargets) {
749
798
  if (isDevEnvironment()) {
@@ -754,8 +803,10 @@ function MarkTheWordsInner(props, ref) {
754
803
  }
755
804
  return;
756
805
  }
806
+ if (props.enableRetry === false) return;
757
807
  if (!passedThreshold || completedRef.current) return;
758
808
  completedRef.current = true;
809
+ setSubmitted(true);
759
810
  setPassed(true);
760
811
  assessment.answer({
761
812
  checkId,
@@ -778,6 +829,7 @@ function MarkTheWordsInner(props, ref) {
778
829
  marked,
779
830
  maxScore,
780
831
  passedThreshold,
832
+ props.enableRetry,
781
833
  props.passingScore,
782
834
  props.correctWords,
783
835
  props.text,
@@ -815,7 +867,8 @@ function MarkTheWordsInner(props, ref) {
815
867
  i
816
868
  );
817
869
  }) }),
818
- allMarked ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { role: "status", "aria-live": "polite", children: "Correct" }) : null,
870
+ passedThreshold ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { role: "status", "aria-live": "polite", children: "Correct" }) : null,
871
+ props.enableRetry === false && hasTargets && marked.size > 0 && !submitted ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { type: "button", "data-testid": "mark-the-words-submit", onClick: submitMarks, children: "Submit" }) : null,
819
872
  props.enableRetry && passed ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { type: "button", onClick: reset, children: "Try again" }) : null,
820
873
  props.enableSolutionsButton && !showSolutions ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { type: "button", onClick: () => setShowSolutions(true), children: "Show solution" }) : null
821
874
  ] });
@@ -856,11 +909,43 @@ function parseTemplate(template) {
856
909
  blanks: values.map((answer, i) => ({ id: `blank-${i}`, answer }))
857
910
  };
858
911
  }
912
+ function normalizeBlanks(blanks) {
913
+ return blanks.map((b) => ({ id: b.id.trim(), answer: b.answer.trim() })).filter((b) => b.id.length > 0 && b.answer.length > 0);
914
+ }
915
+ function resolveBlanks(template, explicitBlanks) {
916
+ const parsed = parseTemplate(template);
917
+ if (!explicitBlanks) {
918
+ return { parts: parsed.parts, blanks: parsed.blanks };
919
+ }
920
+ const normalized = normalizeBlanks(explicitBlanks);
921
+ if (normalized.length !== parsed.blanks.length) {
922
+ if (isDevEnvironment()) {
923
+ console.warn(
924
+ "[lessonkit] FillInTheBlanks: blanks length does not match template; using parsed blanks",
925
+ { templateBlanks: parsed.blanks.length, explicitBlanks: normalized.length }
926
+ );
927
+ }
928
+ return { parts: parsed.parts, blanks: parsed.blanks };
929
+ }
930
+ let blankIdx = 0;
931
+ const interleavedParts = parsed.parts.map((part) => {
932
+ if (part.startsWith("blank-")) {
933
+ const id = normalized[blankIdx]?.id;
934
+ blankIdx += 1;
935
+ return id ?? part;
936
+ }
937
+ return part;
938
+ });
939
+ return { parts: interleavedParts, blanks: normalized };
940
+ }
859
941
  function FillInTheBlanksInner(props, ref) {
860
942
  const checkId = (0, import_react14.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
861
943
  const assessment = useAssessmentState(props.enclosingLessonId);
862
- const parsed = (0, import_react14.useMemo)(() => parseTemplate(props.template), [props.template]);
863
- const blanks = props.blanks ?? parsed.blanks;
944
+ const { config } = useLessonkit();
945
+ const { parts, blanks } = (0, import_react14.useMemo)(
946
+ () => resolveBlanks(props.template, props.blanks),
947
+ [props.template, props.blanks]
948
+ );
864
949
  const [values, setValues] = (0, import_react14.useState)(
865
950
  () => Object.fromEntries(blanks.map((b) => [b.id, ""]))
866
951
  );
@@ -959,13 +1044,14 @@ function FillInTheBlanksInner(props, ref) {
959
1044
  blanks.forEach((b) => {
960
1045
  if ((nextValues[b.id] ?? "").trim().toLowerCase() === b.answer.toLowerCase()) nextScore += 1;
961
1046
  });
962
- replayTelemetry(nextValues, nextPassed, nextSubmitted, nextScore, blanks.length);
1047
+ if (config.tracking?.replayResumeEvents === true) {
1048
+ replayTelemetry(nextValues, nextPassed, nextSubmitted, nextScore, blanks.length);
1049
+ }
963
1050
  }
964
1051
  }),
965
- [allFilled, assessment, blanks, checkId, maxScore, passed, passedThreshold, props.passingScore, props.template, score, showSolutions, submitted, values]
1052
+ [allFilled, assessment, blanks, checkId, config.tracking?.replayResumeEvents, maxScore, passed, passedThreshold, props.passingScore, props.template, score, showSolutions, submitted, values]
966
1053
  );
967
- useAssessmentHandleRegistration(checkId, handle, ref);
968
- const check = () => {
1054
+ const check = (0, import_react14.useCallback)(() => {
969
1055
  if (!hasBlanks) {
970
1056
  if (isDevEnvironment()) {
971
1057
  console.warn("[lessonkit] FillInTheBlanks has no blanks in template");
@@ -973,7 +1059,7 @@ function FillInTheBlanksInner(props, ref) {
973
1059
  return;
974
1060
  }
975
1061
  if (!allFilled) return;
976
- if (passed) return;
1062
+ if (passed && !props.enableRetry) return;
977
1063
  const snapshot = JSON.stringify(values);
978
1064
  if (checkSnapshotRef.current === snapshot) return;
979
1065
  checkSnapshotRef.current = snapshot;
@@ -986,9 +1072,9 @@ function FillInTheBlanksInner(props, ref) {
986
1072
  response: values,
987
1073
  correct: passedThreshold
988
1074
  });
989
- if (passedThreshold && !completedRef.current) {
1075
+ if ((passedThreshold || props.enableRetry === false) && !completedRef.current) {
990
1076
  completedRef.current = true;
991
- setPassed(true);
1077
+ if (passedThreshold) setPassed(true);
992
1078
  assessment.complete({
993
1079
  checkId,
994
1080
  interactionType: INTERACTION3,
@@ -997,7 +1083,22 @@ function FillInTheBlanksInner(props, ref) {
997
1083
  passingScore: props.passingScore ?? maxScore
998
1084
  });
999
1085
  }
1000
- };
1086
+ }, [
1087
+ allFilled,
1088
+ assessment,
1089
+ blanks.length,
1090
+ checkId,
1091
+ hasBlanks,
1092
+ maxScore,
1093
+ passed,
1094
+ passedThreshold,
1095
+ props.enableRetry,
1096
+ props.passingScore,
1097
+ props.template,
1098
+ score,
1099
+ values
1100
+ ]);
1101
+ useAssessmentHandleRegistration(checkId, handle, ref);
1001
1102
  (0, import_react14.useEffect)(() => {
1002
1103
  if (!allFilled) {
1003
1104
  answeredRef.current = false;
@@ -1007,14 +1108,17 @@ function FillInTheBlanksInner(props, ref) {
1007
1108
  }, [allFilled]);
1008
1109
  (0, import_react14.useEffect)(() => {
1009
1110
  if (props.autoCheck && allFilled && !passed) check();
1010
- }, [allFilled, props.autoCheck, values, passedThreshold, passed]);
1111
+ }, [allFilled, check, passed, props.autoCheck]);
1011
1112
  const reveal = showSolutions || passed && props.enableSolutionsButton;
1012
1113
  return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("section", { "aria-label": "Fill in the Blanks", "data-lk-check-id": checkId, children: [
1013
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("p", { children: parsed.parts.map((part, i) => {
1114
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("p", { children: parts.map((part, i) => {
1014
1115
  const blank = blanks.find((b) => b.id === part);
1015
1116
  if (!blank) return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react14.default.Fragment, { children: part }, i);
1016
1117
  return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("label", { style: { margin: "0 0.25em" }, children: [
1017
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "lk-visually-hidden", children: blank.answer }),
1118
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("span", { className: "lk-visually-hidden", children: [
1119
+ "Blank ",
1120
+ blank.id
1121
+ ] }),
1018
1122
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
1019
1123
  "input",
1020
1124
  {
@@ -1031,7 +1135,16 @@ function FillInTheBlanksInner(props, ref) {
1031
1135
  )
1032
1136
  ] }, blank.id);
1033
1137
  }) }),
1034
- !props.autoCheck ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("button", { type: "button", "data-testid": "check-blanks", disabled: !allFilled || passed, onClick: check, children: "Check" }) : null,
1138
+ !props.autoCheck ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
1139
+ "button",
1140
+ {
1141
+ type: "button",
1142
+ "data-testid": "check-blanks",
1143
+ disabled: !allFilled || passed && !props.enableRetry,
1144
+ onClick: check,
1145
+ children: "Check"
1146
+ }
1147
+ ) : null,
1035
1148
  !hasBlanks ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("p", { role: "alert", children: "This activity has no blanks. Add text wrapped in asterisks, e.g. The *answer* here." }) : null,
1036
1149
  submitted ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("p", { role: "status", "aria-live": "polite", children: passed || passedThreshold ? "Correct" : "Try again" }) : null,
1037
1150
  props.enableRetry && passed ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("button", { type: "button", onClick: reset, children: "Try again" }) : null,
@@ -1056,6 +1169,7 @@ function parseZones(template) {
1056
1169
  function DragTheWordsInner(props, ref) {
1057
1170
  const checkId = (0, import_react15.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1058
1171
  const assessment = useAssessmentState(props.enclosingLessonId);
1172
+ const { config } = useLessonkit();
1059
1173
  const { parts, answers } = (0, import_react15.useMemo)(() => parseZones(props.template), [props.template]);
1060
1174
  const [zones, setZones] = (0, import_react15.useState)(
1061
1175
  () => Object.fromEntries(answers.map((_, i) => [`zone-${i}`, ""]))
@@ -1160,10 +1274,12 @@ function DragTheWordsInner(props, ref) {
1160
1274
  answers.forEach((ans, i) => {
1161
1275
  if ((nextZones[`zone-${i}`] ?? "").trim().toLowerCase() === ans.toLowerCase()) nextScore += 1;
1162
1276
  });
1163
- replayTelemetry(nextZones, nextPassed, nextSubmitted, nextScore, answers.length);
1277
+ if (config.tracking?.replayResumeEvents === true) {
1278
+ replayTelemetry(nextZones, nextPassed, nextSubmitted, nextScore, answers.length);
1279
+ }
1164
1280
  }
1165
1281
  }),
1166
- [allFilled, answers, assessment, checkId, keyboardWord, maxScore, passed, passedThreshold, pool, props.passingScore, props.template, score, submitted, zones]
1282
+ [allFilled, answers, assessment, checkId, config.tracking?.replayResumeEvents, keyboardWord, maxScore, passed, passedThreshold, pool, props.passingScore, props.template, score, submitted, zones]
1167
1283
  );
1168
1284
  useAssessmentHandleRegistration(checkId, handle, ref);
1169
1285
  const placeInZone = (zoneId, word) => {
@@ -1206,9 +1322,9 @@ function DragTheWordsInner(props, ref) {
1206
1322
  response: zones,
1207
1323
  correct: passedThreshold
1208
1324
  });
1209
- if (passedThreshold && !completedRef.current) {
1325
+ if ((passedThreshold || props.enableRetry === false) && !completedRef.current) {
1210
1326
  completedRef.current = true;
1211
- setPassed(true);
1327
+ if (passedThreshold) setPassed(true);
1212
1328
  assessment.complete({
1213
1329
  checkId,
1214
1330
  interactionType: INTERACTION4,
@@ -1270,7 +1386,16 @@ function DragTheWordsInner(props, ref) {
1270
1386
  part
1271
1387
  );
1272
1388
  }) }),
1273
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("button", { type: "button", "data-testid": "check-drag-words", disabled: !allFilled || passed, onClick: check, children: "Check" }),
1389
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1390
+ "button",
1391
+ {
1392
+ type: "button",
1393
+ "data-testid": "check-drag-words",
1394
+ disabled: !allFilled || passed && !props.enableRetry,
1395
+ onClick: check,
1396
+ children: "Check"
1397
+ }
1398
+ ),
1274
1399
  !hasZones ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { role: "alert", children: "This activity has no drop zones. Wrap answers in asterisks in the template." }) : null,
1275
1400
  submitted ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { role: "status", "aria-live": "polite", children: passed || passedThreshold ? "Correct" : "Try again" }) : null
1276
1401
  ] });
@@ -1284,6 +1409,37 @@ var DragTheWords = (0, import_react15.forwardRef)(function DragTheWords2(props,
1284
1409
  var import_react16 = require("react");
1285
1410
  var import_jsx_runtime10 = require("react/jsx-runtime");
1286
1411
  var INTERACTION5 = "dragAndDrop";
1412
+ function normalizeDragAndDropState(rawAssignments, rawPool, items, targets) {
1413
+ const itemIds = new Set(items.map((i) => i.id));
1414
+ const targetIds = targets.map((t) => t.id);
1415
+ const assignments = Object.fromEntries(targetIds.map((id) => [id, ""]));
1416
+ if (rawAssignments && typeof rawAssignments === "object") {
1417
+ for (const targetId of targetIds) {
1418
+ const value = rawAssignments[targetId];
1419
+ if (typeof value === "string" && (value === "" || itemIds.has(value))) {
1420
+ assignments[targetId] = value;
1421
+ }
1422
+ }
1423
+ }
1424
+ const assigned = new Set(Object.values(assignments).filter(Boolean));
1425
+ const pool = [];
1426
+ const seen = /* @__PURE__ */ new Set();
1427
+ if (Array.isArray(rawPool)) {
1428
+ for (const id of rawPool) {
1429
+ if (typeof id === "string" && itemIds.has(id) && !assigned.has(id) && !seen.has(id)) {
1430
+ pool.push(id);
1431
+ seen.add(id);
1432
+ }
1433
+ }
1434
+ }
1435
+ for (const item of items) {
1436
+ if (!assigned.has(item.id) && !seen.has(item.id)) {
1437
+ pool.push(item.id);
1438
+ seen.add(item.id);
1439
+ }
1440
+ }
1441
+ return { assignments, pool };
1442
+ }
1287
1443
  function DragAndDropInner(props, ref) {
1288
1444
  const checkId = (0, import_react16.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1289
1445
  const assessment = useAssessmentState(props.enclosingLessonId);
@@ -1333,11 +1489,14 @@ function DragAndDropInner(props, ref) {
1333
1489
  }),
1334
1490
  getCurrentState: () => ({ assignments, pool, passed, checked, keyboardItem }),
1335
1491
  resume: (state) => {
1336
- const rawAssignments = state.assignments;
1337
- if (rawAssignments && typeof rawAssignments === "object") {
1338
- setAssignments({ ...rawAssignments });
1339
- }
1340
- if (Array.isArray(state.pool)) setPool([...state.pool]);
1492
+ const normalized = normalizeDragAndDropState(
1493
+ state.assignments,
1494
+ state.pool,
1495
+ props.items,
1496
+ props.targets
1497
+ );
1498
+ setAssignments(normalized.assignments);
1499
+ setPool(normalized.pool);
1341
1500
  readBooleanStateField(state, "passed", (value) => {
1342
1501
  setPassed(value);
1343
1502
  completedRef.current = value;
@@ -1370,9 +1529,9 @@ function DragAndDropInner(props, ref) {
1370
1529
  response: assignments,
1371
1530
  correct: passedThreshold
1372
1531
  });
1373
- if (passedThreshold && !completedRef.current) {
1532
+ if ((passedThreshold || props.enableRetry === false) && !completedRef.current) {
1374
1533
  completedRef.current = true;
1375
- setPassed(true);
1534
+ if (passedThreshold) setPassed(true);
1376
1535
  assessment.complete({
1377
1536
  checkId,
1378
1537
  interactionType: INTERACTION5,
@@ -1435,7 +1594,16 @@ function DragAndDropInner(props, ref) {
1435
1594
  )
1436
1595
  ] }, target.id);
1437
1596
  }) }),
1438
- /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("button", { type: "button", "data-testid": "check-drag-drop", disabled: !hasTargets || !allFilled || passed, onClick: check, children: "Check" }),
1597
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
1598
+ "button",
1599
+ {
1600
+ type: "button",
1601
+ "data-testid": "check-drag-drop",
1602
+ disabled: !hasTargets || !allFilled || passed && !props.enableRetry,
1603
+ onClick: check,
1604
+ children: "Check"
1605
+ }
1606
+ ),
1439
1607
  checked ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { role: "status", "aria-live": "polite", children: passedThreshold ? "Correct" : "Try again" }) : null
1440
1608
  ] });
1441
1609
  }
@@ -1446,11 +1614,11 @@ var DragAndDrop = (0, import_react16.forwardRef)(function DragAndDrop2(props, re
1446
1614
 
1447
1615
  // src/blocks/AssessmentSequence.tsx
1448
1616
  var import_react22 = __toESM(require("react"), 1);
1449
- var import_core17 = require("@lessonkit/core");
1617
+ var import_core19 = require("@lessonkit/core");
1450
1618
 
1451
1619
  // src/compound/useCompoundShell.ts
1452
1620
  var import_react20 = require("react");
1453
- var import_core15 = require("@lessonkit/core");
1621
+ var import_core17 = require("@lessonkit/core");
1454
1622
 
1455
1623
  // src/compound/useCompoundNavigation.ts
1456
1624
  var import_react17 = require("react");
@@ -1474,28 +1642,132 @@ function useCompoundNavigation(pageCount, index, setIndex) {
1474
1642
 
1475
1643
  // src/compound/useCompoundPersistence.ts
1476
1644
  var import_react19 = require("react");
1477
- var import_core14 = require("@lessonkit/core");
1645
+ var import_core16 = require("@lessonkit/core");
1646
+
1647
+ // src/compound/compoundHydration.ts
1648
+ var hydratedKeys = /* @__PURE__ */ new Set();
1649
+ function compoundHydrationKey(courseId, compoundId) {
1650
+ return `${courseId}:${compoundId}`;
1651
+ }
1652
+ function markCompoundHydrated(key) {
1653
+ hydratedKeys.add(key);
1654
+ }
1655
+ function isCompoundHydrated(key) {
1656
+ return hydratedKeys.has(key);
1657
+ }
1658
+ function clearCompoundHydrated(key) {
1659
+ hydratedKeys.delete(key);
1660
+ }
1661
+
1662
+ // src/compound/useCompoundBranchShell.ts
1663
+ var import_core12 = require("@lessonkit/core");
1664
+ var BS_META_KEY = "__lk_bs__";
1665
+ function createInitialBranchMeta(startNodeId) {
1666
+ return { activeNodeId: startNodeId, visitedNodeIds: [startNodeId] };
1667
+ }
1668
+ function readBranchingScenarioMeta(childStates) {
1669
+ const raw = childStates[BS_META_KEY];
1670
+ if (!raw || typeof raw !== "object") return null;
1671
+ const activeNodeId = typeof raw.activeNodeId === "string" ? raw.activeNodeId : "";
1672
+ const visitedNodeIds = Array.isArray(raw.visitedNodeIds) ? raw.visitedNodeIds.filter((id) => typeof id === "string") : [];
1673
+ const choiceScores = raw.choiceScores && typeof raw.choiceScores === "object" && !Array.isArray(raw.choiceScores) ? raw.choiceScores : void 0;
1674
+ if (!activeNodeId) return null;
1675
+ return { activeNodeId, visitedNodeIds, choiceScores };
1676
+ }
1677
+ function sanitizeBranchMeta(meta, nodeIndexMap, startNodeId, validChoiceKeys) {
1678
+ const knownIds = new Set(nodeIndexMap.keys());
1679
+ const activeNodeId = knownIds.has(meta.activeNodeId) ? meta.activeNodeId : startNodeId;
1680
+ const visitedNodeIds = meta.visitedNodeIds.filter((id) => knownIds.has(id));
1681
+ if (!visitedNodeIds.includes(activeNodeId)) {
1682
+ visitedNodeIds.push(activeNodeId);
1683
+ }
1684
+ if (visitedNodeIds.length === 0) {
1685
+ visitedNodeIds.push(startNodeId);
1686
+ }
1687
+ const choiceScores = meta.choiceScores ? Object.fromEntries(
1688
+ Object.entries(meta.choiceScores).filter(
1689
+ ([key]) => validChoiceKeys ? validChoiceKeys.has(key) : knownIds.has(key.split(":")[0] ?? "")
1690
+ )
1691
+ ) : void 0;
1692
+ return {
1693
+ activeNodeId,
1694
+ visitedNodeIds,
1695
+ ...Object.keys(choiceScores ?? {}).length > 0 ? { choiceScores } : {}
1696
+ };
1697
+ }
1698
+ function mergeBranchMetaIntoState(state, meta) {
1699
+ return {
1700
+ ...state,
1701
+ childStates: {
1702
+ ...state.childStates,
1703
+ [BS_META_KEY]: meta
1704
+ }
1705
+ };
1706
+ }
1707
+ function choiceScoreKey(fromNodeId, toNodeId) {
1708
+ return `${fromNodeId}:${toNodeId}`;
1709
+ }
1710
+ function applyChoiceScoreUpdate(prev, fromNodeId, toNodeId, scoreWeight) {
1711
+ if (scoreWeight === void 0) return prev;
1712
+ const next = { ...prev ?? {} };
1713
+ for (const key of Object.keys(next)) {
1714
+ if (key.startsWith(`${fromNodeId}:`)) {
1715
+ delete next[key];
1716
+ }
1717
+ }
1718
+ next[choiceScoreKey(fromNodeId, toNodeId)] = scoreWeight;
1719
+ return next;
1720
+ }
1721
+ function sumChoiceScores(choiceScores) {
1722
+ if (!choiceScores) return 0;
1723
+ return Object.values(choiceScores).reduce((sum, value) => sum + (Number.isFinite(value) ? value : 0), 0);
1724
+ }
1725
+
1726
+ // src/compound/useCompoundVideoShell.ts
1727
+ var import_core13 = require("@lessonkit/core");
1728
+ var IV_META_KEY = "__lk_iv__";
1729
+ function readInteractiveVideoMeta(childStates) {
1730
+ const raw = childStates[IV_META_KEY];
1731
+ if (!raw || typeof raw !== "object") return null;
1732
+ const currentTime = typeof raw.currentTime === "number" ? raw.currentTime : 0;
1733
+ const completedCueIndices = Array.isArray(raw.completedCueIndices) ? raw.completedCueIndices.filter((n) => typeof n === "number") : [];
1734
+ const firedCueIndices = Array.isArray(raw.firedCueIndices) ? raw.firedCueIndices.filter((n) => typeof n === "number") : completedCueIndices;
1735
+ return { currentTime, completedCueIndices, firedCueIndices };
1736
+ }
1737
+ function mergeVideoMetaIntoState(state, meta) {
1738
+ return {
1739
+ ...state,
1740
+ childStates: {
1741
+ ...state.childStates,
1742
+ [IV_META_KEY]: meta
1743
+ }
1744
+ };
1745
+ }
1478
1746
 
1479
1747
  // src/compound/resumeChildHandles.ts
1480
- function filterRegisteredChildStates(handles, childStates) {
1748
+ var DEFAULT_PRESERVED_CHILD_STATE_KEYS = /* @__PURE__ */ new Set([BS_META_KEY, IV_META_KEY]);
1749
+ function registerablePendingKeys(childStates, preserveKeys = DEFAULT_PRESERVED_CHILD_STATE_KEYS) {
1750
+ return Object.keys(childStates).filter((key) => !preserveKeys.has(key));
1751
+ }
1752
+ function filterRegisteredChildStates(handles, childStates, preserveKeys = DEFAULT_PRESERVED_CHILD_STATE_KEYS) {
1481
1753
  const filtered = {};
1482
1754
  for (const [key, value] of Object.entries(childStates)) {
1483
- if (handles.has(key)) {
1755
+ if (preserveKeys.has(key) || handles.has(key)) {
1484
1756
  filtered[key] = value;
1485
1757
  }
1486
1758
  }
1487
1759
  return filtered;
1488
1760
  }
1489
1761
  function resumeChildHandles(handles, childStates, opts) {
1490
- const pendingKeys = Object.keys(childStates);
1491
1762
  const alreadyResumed = opts?.alreadyResumed;
1492
- if (opts?.waitForHandles && pendingKeys.length > 0) {
1763
+ const registerableKeys = registerablePendingKeys(childStates);
1764
+ if (opts?.waitForHandles && registerableKeys.length > 0) {
1493
1765
  if (handles.size === 0) return false;
1494
- const registeredPending = pendingKeys.filter((k) => handles.has(k));
1766
+ const registeredPending = registerableKeys.filter((k) => handles.has(k));
1495
1767
  if (registeredPending.length === 0) {
1496
1768
  return false;
1497
1769
  }
1498
- if (registeredPending.length < pendingKeys.length) {
1770
+ if (registeredPending.length < registerableKeys.length) {
1499
1771
  for (const key of registeredPending) {
1500
1772
  if (alreadyResumed?.has(key)) continue;
1501
1773
  const handle = handles.get(key);
@@ -1521,24 +1793,36 @@ function resumeChildHandles(handles, childStates, opts) {
1521
1793
 
1522
1794
  // src/compound/useCompoundResume.ts
1523
1795
  var import_react18 = require("react");
1524
- var import_core12 = require("@lessonkit/core");
1525
- var import_core13 = require("@lessonkit/core");
1526
- var warnedCompoundPersistFailure = false;
1527
- function warnCompoundPersistFailure() {
1528
- if (warnedCompoundPersistFailure || !isDevEnvironment()) return;
1529
- warnedCompoundPersistFailure = true;
1796
+ var import_core14 = require("@lessonkit/core");
1797
+ var import_core15 = require("@lessonkit/core");
1798
+
1799
+ // src/compound/compoundLoadOpts.ts
1800
+ function compoundLoadOpts(ctx, compoundId) {
1801
+ const onCorruptHook = ctx?.config?.observability?.onCompoundResumeCorrupt;
1802
+ if (!onCorruptHook) return void 0;
1803
+ return {
1804
+ onCorrupt: () => onCorruptHook({ compoundId, corrupt: true }),
1805
+ onDroppedChildKeys: (droppedChildKeys) => onCorruptHook({ compoundId, droppedChildKeys })
1806
+ };
1807
+ }
1808
+
1809
+ // src/compound/useCompoundResume.ts
1810
+ var warnedCompoundPersistFailures = /* @__PURE__ */ new Set();
1811
+ function warnCompoundPersistFailure(compoundId) {
1812
+ if (warnedCompoundPersistFailures.has(compoundId) || !isDevEnvironment()) return;
1813
+ warnedCompoundPersistFailures.add(compoundId);
1530
1814
  console.warn(
1531
- "[lessonkit] compound resume state could not be saved to sessionStorage (quota or privacy mode); progress may be lost on reload."
1815
+ `[lessonkit] compound resume state for "${compoundId}" could not be saved to sessionStorage (quota or privacy mode); progress may be lost on reload.`
1532
1816
  );
1533
1817
  }
1534
1818
  function useCompoundResume(opts) {
1535
1819
  const lessonkitCtx = (0, import_react18.useContext)(LessonkitContext);
1536
- const storageRef = (0, import_react18.useRef)(opts.storage ?? lessonkitCtx?.storage ?? (0, import_core13.createSessionStoragePort)());
1820
+ const storageRef = (0, import_react18.useRef)(opts.storage ?? lessonkitCtx?.storage ?? (0, import_core15.createSessionStoragePort)());
1537
1821
  const resumedRef = (0, import_react18.useRef)(false);
1538
1822
  const resumeKeyRef = (0, import_react18.useRef)("");
1539
1823
  const prevEnabledRef = (0, import_react18.useRef)(opts.enabled);
1540
1824
  (0, import_react18.useEffect)(() => {
1541
- storageRef.current = opts.storage ?? lessonkitCtx?.storage ?? (0, import_core13.createSessionStoragePort)();
1825
+ storageRef.current = opts.storage ?? lessonkitCtx?.storage ?? (0, import_core15.createSessionStoragePort)();
1542
1826
  }, [opts.storage, lessonkitCtx?.storage]);
1543
1827
  (0, import_react18.useEffect)(() => {
1544
1828
  if (!prevEnabledRef.current && opts.enabled) {
@@ -1551,7 +1835,16 @@ function useCompoundResume(opts) {
1551
1835
  resumedRef.current = false;
1552
1836
  }
1553
1837
  if (!opts.enabled || !opts.courseId || resumedRef.current) return;
1554
- const saved = (0, import_core12.loadCompoundState)(storageRef.current, opts.courseId, opts.compoundId);
1838
+ if (isCompoundHydrated(key)) {
1839
+ resumedRef.current = true;
1840
+ return;
1841
+ }
1842
+ const saved = (0, import_core14.loadCompoundState)(
1843
+ storageRef.current,
1844
+ opts.courseId,
1845
+ opts.compoundId,
1846
+ compoundLoadOpts(lessonkitCtx, opts.compoundId)
1847
+ );
1555
1848
  if (saved) {
1556
1849
  resumedRef.current = true;
1557
1850
  opts.onResume?.(saved);
@@ -1560,26 +1853,31 @@ function useCompoundResume(opts) {
1560
1853
  return (0, import_react18.useCallback)(
1561
1854
  (state) => {
1562
1855
  if (!opts.enabled || !opts.courseId) return;
1563
- const persisted = (0, import_core12.saveCompoundState)(storageRef.current, opts.courseId, opts.compoundId, state);
1564
- if (!persisted) warnCompoundPersistFailure();
1856
+ const persisted = (0, import_core14.saveCompoundState)(storageRef.current, opts.courseId, opts.compoundId, state);
1857
+ if (!persisted) warnCompoundPersistFailure(opts.compoundId);
1565
1858
  },
1566
1859
  [opts.enabled, opts.courseId, opts.compoundId]
1567
1860
  );
1568
1861
  }
1569
1862
 
1570
1863
  // src/compound/useCompoundPersistence.ts
1571
- function readCompoundInitialIndex(courseId, compoundId, pageCount, enabled, storage = (0, import_core14.createSessionStoragePort)()) {
1864
+ var MAX_HYDRATION_RETRIES = 10;
1865
+ function isEmptyResumeState(state) {
1866
+ if (!state || typeof state !== "object") return true;
1867
+ return Object.keys(state).length === 0;
1868
+ }
1869
+ function readCompoundInitialIndex(courseId, compoundId, pageCount, enabled, storage = (0, import_core16.createSessionStoragePort)()) {
1572
1870
  if (!enabled || !courseId || pageCount < 1) return 0;
1573
- const saved = (0, import_core14.loadCompoundState)(storage, courseId, compoundId);
1871
+ const saved = (0, import_core16.loadCompoundState)(storage, courseId, compoundId);
1574
1872
  if (!saved) return 0;
1575
- return (0, import_core14.clampCompoundPageIndex)(saved.activePageIndex, pageCount);
1873
+ return (0, import_core16.clampCompoundPageIndex)(saved.activePageIndex, pageCount);
1576
1874
  }
1577
1875
  function stripOrphanChildStates(handles, childStates) {
1578
1876
  return filterRegisteredChildStates(handles, childStates);
1579
1877
  }
1580
1878
  function useCompoundPersistence(opts) {
1581
1879
  const lessonkitCtx = (0, import_react19.useContext)(LessonkitContext);
1582
- const storage = opts.storage ?? lessonkitCtx?.storage ?? (0, import_core14.createSessionStoragePort)();
1880
+ const storage = opts.storage ?? lessonkitCtx?.storage ?? (0, import_core16.createSessionStoragePort)();
1583
1881
  const ctx = useCompoundRegistry();
1584
1882
  const handlesVersion = useCompoundHandlesVersion();
1585
1883
  const bridgeRef = useCompoundHydrationBridgeRef();
@@ -1587,25 +1885,21 @@ function useCompoundPersistence(opts) {
1587
1885
  const resumedChildKeysRef = (0, import_react19.useRef)(/* @__PURE__ */ new Set());
1588
1886
  const loadedChildStatesRef = (0, import_react19.useRef)({});
1589
1887
  const skipSaveUntilHydratedRef = (0, import_react19.useRef)(false);
1888
+ const postResumePersistPendingRef = (0, import_react19.useRef)(false);
1590
1889
  const hydrationKeyRef = (0, import_react19.useRef)("");
1591
- const hydrationInitRef = (0, import_react19.useRef)(false);
1890
+ const hydrationRetryRef = (0, import_react19.useRef)(0);
1592
1891
  const hydrationKey = `${opts.courseId ?? ""}:${opts.compoundId}`;
1593
1892
  if (hydrationKeyRef.current !== hydrationKey) {
1893
+ if (hydrationKeyRef.current) {
1894
+ clearCompoundHydrated(hydrationKeyRef.current);
1895
+ }
1594
1896
  hydrationKeyRef.current = hydrationKey;
1595
- hydrationInitRef.current = false;
1596
1897
  loadedChildStatesRef.current = {};
1597
1898
  skipSaveUntilHydratedRef.current = false;
1899
+ postResumePersistPendingRef.current = false;
1598
1900
  pendingChildResumeRef.current = null;
1599
1901
  resumedChildKeysRef.current = /* @__PURE__ */ new Set();
1600
- }
1601
- if (!hydrationInitRef.current && opts.enabled && opts.courseId) {
1602
- hydrationInitRef.current = true;
1603
- const saved = (0, import_core14.loadCompoundState)(storage, opts.courseId, opts.compoundId);
1604
- if (saved && Object.keys(saved.childStates).length > 0) {
1605
- loadedChildStatesRef.current = { ...saved.childStates };
1606
- skipSaveUntilHydratedRef.current = true;
1607
- pendingChildResumeRef.current = saved;
1608
- }
1902
+ hydrationRetryRef.current = 0;
1609
1903
  }
1610
1904
  const buildState = (0, import_react19.useCallback)(() => {
1611
1905
  const childStates = {
@@ -1613,22 +1907,35 @@ function useCompoundPersistence(opts) {
1613
1907
  };
1614
1908
  if (ctx) {
1615
1909
  for (const [checkId, entry] of ctx.getRegisteredHandles()) {
1910
+ if (opts.shouldIncludeChildState && !opts.shouldIncludeChildState(checkId, entry.pageIndex)) {
1911
+ continue;
1912
+ }
1616
1913
  const handle = entry.handle;
1617
1914
  if (handle.getCurrentState) {
1618
- childStates[checkId] = handle.getCurrentState();
1619
- delete loadedChildStatesRef.current[checkId];
1915
+ const live = handle.getCurrentState();
1916
+ const loaded = loadedChildStatesRef.current[checkId];
1917
+ if (loaded !== void 0 && isEmptyResumeState(live)) {
1918
+ childStates[checkId] = loaded;
1919
+ } else {
1920
+ childStates[checkId] = live;
1921
+ if (!isEmptyResumeState(live)) {
1922
+ delete loadedChildStatesRef.current[checkId];
1923
+ }
1924
+ }
1620
1925
  }
1621
1926
  }
1622
1927
  }
1623
- return (0, import_core14.createCompoundResumeState)({
1624
- activePageIndex: (0, import_core14.clampCompoundPageIndex)(opts.index, opts.pageCount),
1928
+ return (0, import_core16.createCompoundResumeState)({
1929
+ activePageIndex: (0, import_core16.clampCompoundPageIndex)(opts.index, opts.pageCount),
1625
1930
  childStates
1626
1931
  });
1627
- }, [ctx, opts.index, opts.pageCount]);
1932
+ }, [ctx, opts.index, opts.pageCount, opts.shouldIncludeChildState]);
1628
1933
  const buildStateRef = (0, import_react19.useRef)(buildState);
1629
1934
  buildStateRef.current = buildState;
1630
1935
  const transformStateRef = (0, import_react19.useRef)(opts.transformState);
1631
1936
  transformStateRef.current = opts.transformState;
1937
+ const onCompoundResumeRef = (0, import_react19.useRef)(opts.onCompoundResume);
1938
+ onCompoundResumeRef.current = opts.onCompoundResume;
1632
1939
  const persistNowRef = (0, import_react19.useRef)(() => {
1633
1940
  });
1634
1941
  const finalizeHydration = (0, import_react19.useCallback)(
@@ -1639,10 +1946,19 @@ function useCompoundPersistence(opts) {
1639
1946
  };
1640
1947
  skipSaveUntilHydratedRef.current = false;
1641
1948
  pendingChildResumeRef.current = null;
1642
- queueMicrotask(() => persistNowRef.current());
1949
+ hydrationRetryRef.current = 0;
1950
+ postResumePersistPendingRef.current = true;
1951
+ requestAnimationFrame(() => {
1952
+ requestAnimationFrame(() => {
1953
+ postResumePersistPendingRef.current = false;
1954
+ persistNowRef.current();
1955
+ });
1956
+ });
1643
1957
  },
1644
1958
  []
1645
1959
  );
1960
+ const applyPendingChildResumeRef = (0, import_react19.useRef)(() => {
1961
+ });
1646
1962
  const applyPendingChildResume = (0, import_react19.useCallback)(() => {
1647
1963
  const pending = pendingChildResumeRef.current;
1648
1964
  if (!pending || !ctx) return;
@@ -1652,62 +1968,125 @@ function useCompoundPersistence(opts) {
1652
1968
  alreadyResumed: resumedChildKeysRef.current
1653
1969
  });
1654
1970
  if (!applied) {
1655
- if (handles.size === 0) {
1656
- const registeredOnly2 = stripOrphanChildStates(handles, pending.childStates);
1657
- resumeChildHandles(handles, registeredOnly2, {
1658
- alreadyResumed: resumedChildKeysRef.current
1659
- });
1660
- finalizeHydration(registeredOnly2);
1971
+ const registerable = registerablePendingKeys(pending.childStates);
1972
+ const missing = registerable.filter((k) => !handles.has(k));
1973
+ if (missing.length > 0 && hydrationRetryRef.current < MAX_HYDRATION_RETRIES) {
1974
+ hydrationRetryRef.current += 1;
1975
+ requestAnimationFrame(() => applyPendingChildResumeRef.current());
1661
1976
  return;
1662
1977
  }
1663
- const handlesAtWait = handles.size;
1664
- queueMicrotask(() => {
1665
- if (pendingChildResumeRef.current !== pending) return;
1666
- const handlesNow = ctx.getHandles();
1667
- if (handlesNow.size !== handlesAtWait) return;
1668
- const registeredOnly2 = stripOrphanChildStates(handlesNow, pending.childStates);
1669
- resumeChildHandles(handlesNow, registeredOnly2, {
1670
- alreadyResumed: resumedChildKeysRef.current
1671
- });
1672
- finalizeHydration(registeredOnly2);
1978
+ if (missing.length > 0 && isDevEnvironment()) {
1979
+ console.warn(
1980
+ `[lessonkit] Compound hydration: ${missing.length} child state(s) not restored (missing handles: ${missing.join(", ")})`
1981
+ );
1982
+ }
1983
+ lessonkitCtx?.config?.observability?.onCompoundHydrationPartial?.({
1984
+ compoundId: opts.compoundId,
1985
+ missingCheckIds: missing
1986
+ });
1987
+ for (const key of missing) {
1988
+ const state = pending.childStates[key];
1989
+ if (state) {
1990
+ loadedChildStatesRef.current[key] = state;
1991
+ }
1992
+ }
1993
+ const registeredOnly2 = stripOrphanChildStates(handles, pending.childStates);
1994
+ resumeChildHandles(handles, registeredOnly2, {
1995
+ alreadyResumed: resumedChildKeysRef.current
1996
+ });
1997
+ finalizeHydration({
1998
+ ...loadedChildStatesRef.current,
1999
+ ...registeredOnly2
1673
2000
  });
1674
2001
  return;
1675
2002
  }
1676
2003
  const registeredOnly = stripOrphanChildStates(handles, pending.childStates);
1677
2004
  finalizeHydration(registeredOnly);
1678
2005
  }, [ctx, finalizeHydration]);
2006
+ applyPendingChildResumeRef.current = applyPendingChildResume;
2007
+ (0, import_react19.useLayoutEffect)(() => {
2008
+ if (!opts.enabled || !opts.courseId) return;
2009
+ markCompoundHydrated(compoundHydrationKey(opts.courseId, opts.compoundId));
2010
+ const saved = (0, import_core16.loadCompoundState)(storage, opts.courseId, opts.compoundId);
2011
+ if (!saved) return;
2012
+ if (Object.keys(saved.childStates).length > 0) {
2013
+ loadedChildStatesRef.current = { ...saved.childStates };
2014
+ skipSaveUntilHydratedRef.current = true;
2015
+ pendingChildResumeRef.current = saved;
2016
+ }
2017
+ const clamped = (0, import_core16.clampCompoundPageIndex)(saved.activePageIndex, opts.pageCount);
2018
+ onCompoundResumeRef.current?.({ ...saved, activePageIndex: clamped });
2019
+ opts.setIndex(clamped);
2020
+ queueMicrotask(() => applyPendingChildResumeRef.current());
2021
+ }, [hydrationKey, opts.courseId, opts.compoundId, opts.enabled, opts.pageCount, storage]);
1679
2022
  const saveResume = useCompoundResume({
1680
2023
  courseId: opts.courseId,
1681
2024
  compoundId: opts.compoundId,
1682
2025
  enabled: opts.enabled,
1683
2026
  storage,
1684
2027
  onResume: (state) => {
1685
- const clamped = (0, import_core14.clampCompoundPageIndex)(state.activePageIndex, opts.pageCount);
2028
+ const clamped = (0, import_core16.clampCompoundPageIndex)(state.activePageIndex, opts.pageCount);
1686
2029
  loadedChildStatesRef.current = { ...state.childStates };
1687
2030
  skipSaveUntilHydratedRef.current = Object.keys(state.childStates).length > 0;
2031
+ onCompoundResumeRef.current?.({ ...state, activePageIndex: clamped });
1688
2032
  opts.setIndex(clamped);
1689
2033
  resumedChildKeysRef.current = /* @__PURE__ */ new Set();
2034
+ hydrationRetryRef.current = 0;
1690
2035
  pendingChildResumeRef.current = { ...state, activePageIndex: clamped, childStates: state.childStates };
1691
2036
  queueMicrotask(() => applyPendingChildResume());
1692
2037
  }
1693
2038
  });
1694
- const persistNow = (0, import_react19.useCallback)(() => {
1695
- if (!opts.enabled || !opts.courseId) return;
1696
- if (skipSaveUntilHydratedRef.current) return;
1697
- const built = buildStateRef.current();
1698
- const state = transformStateRef.current ? transformStateRef.current(built) : built;
1699
- saveResume(state);
1700
- }, [opts.enabled, opts.courseId, saveResume]);
2039
+ const buildBestEffortState = (0, import_react19.useCallback)(() => {
2040
+ const childStates = {
2041
+ ...loadedChildStatesRef.current
2042
+ };
2043
+ if (ctx) {
2044
+ for (const [checkId, entry] of ctx.getRegisteredHandles()) {
2045
+ if (opts.shouldIncludeChildState && !opts.shouldIncludeChildState(checkId, entry.pageIndex)) {
2046
+ continue;
2047
+ }
2048
+ const handle = entry.handle;
2049
+ if (handle.getCurrentState) {
2050
+ const live = handle.getCurrentState();
2051
+ if (!isEmptyResumeState(live)) {
2052
+ childStates[checkId] = live;
2053
+ }
2054
+ }
2055
+ }
2056
+ }
2057
+ const built = (0, import_core16.createCompoundResumeState)({
2058
+ activePageIndex: (0, import_core16.clampCompoundPageIndex)(opts.index, opts.pageCount),
2059
+ childStates
2060
+ });
2061
+ return transformStateRef.current ? transformStateRef.current(built) : built;
2062
+ }, [ctx, opts.index, opts.pageCount, opts.shouldIncludeChildState]);
2063
+ const persistNow = (0, import_react19.useCallback)(
2064
+ (options) => {
2065
+ if (!opts.enabled || !opts.courseId) return;
2066
+ if (options?.forceDuringHydration) {
2067
+ saveResume(buildBestEffortState());
2068
+ return;
2069
+ }
2070
+ if (skipSaveUntilHydratedRef.current) return;
2071
+ if (postResumePersistPendingRef.current) return;
2072
+ const built = buildStateRef.current();
2073
+ const state = transformStateRef.current ? transformStateRef.current(built) : built;
2074
+ saveResume(state);
2075
+ },
2076
+ [opts.enabled, opts.courseId, saveResume, buildBestEffortState]
2077
+ );
1701
2078
  (0, import_react19.useEffect)(() => {
1702
2079
  persistNowRef.current = persistNow;
1703
2080
  }, [persistNow]);
1704
2081
  const notifyImperativeResume = (0, import_react19.useCallback)(
1705
2082
  (state) => {
1706
- const clamped = (0, import_core14.clampCompoundPageIndex)(state.activePageIndex, opts.pageCount);
2083
+ const clamped = (0, import_core16.clampCompoundPageIndex)(state.activePageIndex, opts.pageCount);
1707
2084
  loadedChildStatesRef.current = { ...state.childStates };
1708
2085
  skipSaveUntilHydratedRef.current = Object.keys(state.childStates).length > 0;
2086
+ onCompoundResumeRef.current?.({ ...state, activePageIndex: clamped });
1709
2087
  opts.setIndex(clamped);
1710
2088
  resumedChildKeysRef.current = /* @__PURE__ */ new Set();
2089
+ hydrationRetryRef.current = 0;
1711
2090
  pendingChildResumeRef.current = { ...state, activePageIndex: clamped, childStates: state.childStates };
1712
2091
  queueMicrotask(() => applyPendingChildResume());
1713
2092
  },
@@ -1727,17 +2106,22 @@ function useCompoundPersistence(opts) {
1727
2106
  }, [opts.index, handlesVersion, applyPendingChildResume]);
1728
2107
  (0, import_react19.useEffect)(() => {
1729
2108
  persistNow();
1730
- }, [persistNow, opts.index, opts.pageCount, handlesVersion]);
2109
+ }, [persistNow, opts.index, opts.pageCount, handlesVersion, opts.persistTrigger]);
1731
2110
  (0, import_react19.useEffect)(() => {
1732
2111
  if (!opts.enabled || !opts.courseId || typeof document === "undefined") return;
1733
2112
  const flushOnExit = () => {
1734
- if (document.visibilityState === "hidden") persistNow();
2113
+ if (document.visibilityState === "hidden") {
2114
+ persistNow({ forceDuringHydration: skipSaveUntilHydratedRef.current });
2115
+ }
1735
2116
  };
1736
2117
  document.addEventListener("visibilitychange", flushOnExit);
1737
- window.addEventListener("pagehide", flushOnExit);
2118
+ const flushOnPageHide = () => {
2119
+ persistNow({ forceDuringHydration: skipSaveUntilHydratedRef.current });
2120
+ };
2121
+ window.addEventListener("pagehide", flushOnPageHide);
1738
2122
  return () => {
1739
2123
  document.removeEventListener("visibilitychange", flushOnExit);
1740
- window.removeEventListener("pagehide", flushOnExit);
2124
+ window.removeEventListener("pagehide", flushOnPageHide);
1741
2125
  };
1742
2126
  }, [opts.enabled, opts.courseId, persistNow]);
1743
2127
  }
@@ -1753,10 +2137,12 @@ function useCompoundShell(opts) {
1753
2137
  setIndex: opts.setIndex,
1754
2138
  enabled: opts.persistEnabled,
1755
2139
  storage: opts.storage,
1756
- transformState: opts.transformState
2140
+ transformState: opts.transformState,
2141
+ persistTrigger: opts.persistTrigger,
2142
+ onCompoundResume: opts.onCompoundResume
1757
2143
  });
1758
2144
  const { goNext, goPrev, progress } = useCompoundNavigation(opts.pageCount, opts.index, opts.setIndex);
1759
- const visibleIndex = (0, import_core15.clampCompoundPageIndex)(opts.index, opts.pageCount);
2145
+ const visibleIndex = (0, import_core17.clampCompoundPageIndex)(opts.index, opts.pageCount);
1760
2146
  useCompoundHandleRef(opts.ref, {
1761
2147
  activePageIndex: visibleIndex,
1762
2148
  setActivePageIndex: opts.setIndex,
@@ -1782,7 +2168,7 @@ function useCompoundInitialIndex(opts) {
1782
2168
 
1783
2169
  // src/compound/validateChildren.ts
1784
2170
  var import_react21 = __toESM(require("react"), 1);
1785
- var import_core16 = require("@lessonkit/core");
2171
+ var import_core18 = require("@lessonkit/core");
1786
2172
 
1787
2173
  // src/compound/blockType.ts
1788
2174
  var LESSONKIT_BLOCK_TYPE = /* @__PURE__ */ Symbol.for("lessonkit.blockType");
@@ -1810,7 +2196,9 @@ var COMPOUND_CONTAINER_TYPES = /* @__PURE__ */ new Set([
1810
2196
  "SlideDeck",
1811
2197
  "TimedCue",
1812
2198
  "InteractiveVideo",
1813
- "AssessmentSequence"
2199
+ "AssessmentSequence",
2200
+ "BranchingScenario",
2201
+ "BranchNode"
1814
2202
  ]);
1815
2203
  function warnOrThrow(msg, strict) {
1816
2204
  if (strict) throw new Error(msg);
@@ -1829,7 +2217,7 @@ function validateNode(parent, node, depth, strict) {
1829
2217
  }
1830
2218
  return;
1831
2219
  }
1832
- if (!(0, import_core16.isChildTypeAllowed)(parent, blockType)) {
2220
+ if (!(0, import_core18.isChildTypeAllowed)(parent, blockType)) {
1833
2221
  const key = `${parent}:${blockType}`;
1834
2222
  if (!warnedPairs.has(key)) {
1835
2223
  warnedPairs.add(key);
@@ -1839,7 +2227,7 @@ function validateNode(parent, node, depth, strict) {
1839
2227
  }
1840
2228
  }
1841
2229
  if (COMPOUND_CONTAINER_TYPES.has(blockType)) {
1842
- const maxDepth = import_core16.COMPOUND_MAX_NESTING_DEPTH[parent];
2230
+ const maxDepth = import_core18.COMPOUND_MAX_NESTING_DEPTH[parent];
1843
2231
  if (depth >= maxDepth) {
1844
2232
  warnOrThrow(
1845
2233
  `[lessonkit] Block "${blockType}" exceeds max nesting depth (${maxDepth}) for "${parent}"`,
@@ -1854,7 +2242,7 @@ function validateNode(parent, node, depth, strict) {
1854
2242
  } else if (child.props && typeof child.props === "object" && "children" in child.props) {
1855
2243
  validateSubtreeForForbidden(
1856
2244
  child.props.children,
1857
- import_core16.ACCORDION_FORBIDDEN_CHILD_TYPES,
2245
+ import_core18.ACCORDION_FORBIDDEN_CHILD_TYPES,
1858
2246
  strict
1859
2247
  );
1860
2248
  }
@@ -1882,23 +2270,29 @@ function validateSubtreeForForbidden(node, forbidden, strict) {
1882
2270
  });
1883
2271
  }
1884
2272
  function validateAccordionSections(sections, strict) {
1885
- if (!isDevEnvironment() && !strict) return;
2273
+ const enforceStrict = strict ?? !isDevEnvironment();
1886
2274
  for (const section of sections) {
1887
- validateSubtreeForForbidden(section.content, import_core16.ACCORDION_FORBIDDEN_CHILD_TYPES, strict);
2275
+ validateSubtreeForForbidden(section.content, import_core18.ACCORDION_FORBIDDEN_CHILD_TYPES, enforceStrict);
1888
2276
  }
1889
2277
  }
1890
2278
  function validateCompoundChildren(parent, children, strict) {
1891
- if (!isDevEnvironment() && !strict) return;
1892
- validateNode(parent, children, 0, strict);
2279
+ const enforceStrict = strict ?? !isDevEnvironment();
2280
+ validateNode(parent, children, 0, enforceStrict);
1893
2281
  }
1894
2282
 
1895
- // src/compound/warnPersistence.ts
1896
- var DEFAULT_ASSESSMENT_SEQUENCE_COMPOUND_ID = "assessment-sequence";
1897
- function warnSharedCompoundStorageKey(opts) {
1898
- if (!opts.persistEnabled || opts.hasExplicitBlockId || !isDevEnvironment()) return;
1899
- console.warn(
1900
- `[lessonkit] <${opts.componentName}> without blockId shares one sessionStorage key when persistCompoundState is enabled; set a unique blockId per instance.`
1901
- );
2283
+ // src/compound/requireCompoundBlockId.ts
2284
+ var MissingCompoundBlockIdError = class extends Error {
2285
+ constructor(componentName) {
2286
+ super(
2287
+ `[lessonkit] <${componentName}> requires a unique blockId when session.persistCompoundState is enabled`
2288
+ );
2289
+ this.name = "MissingCompoundBlockIdError";
2290
+ }
2291
+ };
2292
+ function requireCompoundBlockIdWhenPersisting(opts) {
2293
+ if (opts.persistEnabled && !opts.blockId) {
2294
+ throw new MissingCompoundBlockIdError(opts.componentName);
2295
+ }
1902
2296
  }
1903
2297
 
1904
2298
  // src/blocks/AssessmentSequence.tsx
@@ -1907,7 +2301,10 @@ var AssessmentSequenceInner = (0, import_react22.forwardRef)(
1907
2301
  function AssessmentSequenceInner2(props, ref) {
1908
2302
  const { compoundId, childArray, index, setIndex, persistEnabled } = props;
1909
2303
  const sequential = props.sequential !== false;
2304
+ const requireAnswerBeforeNext = props.requireAnswerBeforeNext !== false;
1910
2305
  const { config } = useLessonkit();
2306
+ const registry = useCompoundRegistry();
2307
+ const handlesVersion = useCompoundHandlesVersion();
1911
2308
  const { visibleIndex, goNext, goPrev, progress } = useCompoundShell({
1912
2309
  courseId: config.courseId,
1913
2310
  compoundId,
@@ -1919,6 +2316,21 @@ var AssessmentSequenceInner = (0, import_react22.forwardRef)(
1919
2316
  enableSolutionsButton: props.enableSolutionsButton
1920
2317
  });
1921
2318
  validateCompoundChildren("AssessmentSequence", props.children);
2319
+ const activeStepAnswered = (0, import_react22.useMemo)(() => {
2320
+ if (!requireAnswerBeforeNext || !registry) return true;
2321
+ let handlesForStep = 0;
2322
+ for (const entry of registry.getRegisteredHandles().values()) {
2323
+ if (entry.pageIndex !== visibleIndex) continue;
2324
+ handlesForStep += 1;
2325
+ if (!entry.handle.getAnswerGiven()) return false;
2326
+ }
2327
+ if (handlesForStep === 0) {
2328
+ const child = childArray[visibleIndex];
2329
+ const childProps = child?.props;
2330
+ if (child && typeof childProps?.checkId === "string") return false;
2331
+ }
2332
+ return true;
2333
+ }, [childArray, handlesVersion, registry, requireAnswerBeforeNext, visibleIndex]);
1922
2334
  if (!sequential) {
1923
2335
  return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("section", { "aria-label": "Assessment sequence", "data-testid": "assessment-sequence", children: props.children });
1924
2336
  }
@@ -1946,7 +2358,7 @@ var AssessmentSequenceInner = (0, import_react22.forwardRef)(
1946
2358
  {
1947
2359
  type: "button",
1948
2360
  "data-testid": "sequence-next",
1949
- disabled: visibleIndex >= childArray.length - 1 || childArray.length === 0,
2361
+ disabled: visibleIndex >= childArray.length - 1 || childArray.length === 0 || !activeStepAnswered,
1950
2362
  onClick: goNext,
1951
2363
  children: "Next"
1952
2364
  }
@@ -1960,24 +2372,24 @@ var AssessmentSequence = (0, import_react22.forwardRef)(
1960
2372
  const reactInstanceId = (0, import_react22.useId)();
1961
2373
  const autoCompoundIdRef = (0, import_react22.useRef)(null);
1962
2374
  if (!props.blockId && !autoCompoundIdRef.current) {
1963
- autoCompoundIdRef.current = (0, import_core17.deriveId)(`assessment-sequence-${reactInstanceId}`);
2375
+ autoCompoundIdRef.current = (0, import_core19.deriveId)(`assessment-sequence-${reactInstanceId}`);
1964
2376
  }
1965
- const compoundId = (0, import_react22.useMemo)(
1966
- () => props.blockId ? normalizeComponentId(props.blockId, "blockId") : autoCompoundIdRef.current ?? DEFAULT_ASSESSMENT_SEQUENCE_COMPOUND_ID,
1967
- [props.blockId]
1968
- );
2377
+ const compoundId = (0, import_react22.useMemo)(() => {
2378
+ if (props.blockId) {
2379
+ return normalizeComponentId(props.blockId, "blockId");
2380
+ }
2381
+ return autoCompoundIdRef.current ?? (0, import_core19.deriveId)(`assessment-sequence-${reactInstanceId}`);
2382
+ }, [props.blockId, reactInstanceId]);
1969
2383
  const childArray = import_react22.default.Children.toArray(props.children).filter(
1970
2384
  import_react22.default.isValidElement
1971
2385
  );
1972
2386
  const { config, storage } = useLessonkit();
1973
2387
  const persistEnabled = config.session?.persistCompoundState !== false;
1974
- (0, import_react22.useEffect)(() => {
1975
- warnSharedCompoundStorageKey({
1976
- persistEnabled,
1977
- hasExplicitBlockId: Boolean(props.blockId),
1978
- componentName: "AssessmentSequence"
1979
- });
1980
- }, [persistEnabled, props.blockId]);
2388
+ requireCompoundBlockIdWhenPersisting({
2389
+ persistEnabled,
2390
+ blockId: props.blockId,
2391
+ componentName: "AssessmentSequence"
2392
+ });
1981
2393
  const initialIndex = useCompoundInitialIndex({
1982
2394
  courseId: config.courseId,
1983
2395
  compoundId,
@@ -2022,13 +2434,180 @@ function Heading(props) {
2022
2434
  }
2023
2435
  setLessonkitBlockType(Heading, "Heading");
2024
2436
 
2437
+ // src/blocks/embedSecurity.ts
2438
+ var import_meta = {};
2439
+ var BLOCKED_SANDBOX_TOKENS = /* @__PURE__ */ new Set([
2440
+ "allow-top-navigation",
2441
+ "allow-top-navigation-by-user-activation",
2442
+ "allow-modals",
2443
+ "allow-downloads",
2444
+ "allow-popups-to-escape-sandbox"
2445
+ ]);
2446
+ var ALLOWED_SANDBOX_TOKENS = /* @__PURE__ */ new Set([
2447
+ "allow-forms",
2448
+ "allow-popups",
2449
+ "allow-presentation"
2450
+ ]);
2451
+ var DEFAULT_SANDBOX = "allow-scripts";
2452
+ function isProductionEmbedBuild() {
2453
+ try {
2454
+ if (import_meta.env?.PROD === true) return true;
2455
+ } catch {
2456
+ }
2457
+ const g = globalThis;
2458
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
2459
+ }
2460
+ function allowedEmbedSchemes() {
2461
+ return isProductionEmbedBuild() ? /* @__PURE__ */ new Set(["https:"]) : /* @__PURE__ */ new Set(["https:", "http:"]);
2462
+ }
2463
+ function normalizeHostname(hostname) {
2464
+ return hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
2465
+ }
2466
+ function expandIpv4Literal(hostname) {
2467
+ if (/^\d+$/.test(hostname)) {
2468
+ const value2 = Number(hostname);
2469
+ if (!Number.isInteger(value2) || value2 < 0 || value2 > 4294967295) return null;
2470
+ return `${value2 >>> 24 & 255}.${value2 >>> 16 & 255}.${value2 >>> 8 & 255}.${value2 & 255}`;
2471
+ }
2472
+ if (!/^\d+(?:\.\d+){1,3}$/.test(hostname)) return null;
2473
+ const parts = hostname.split(".").map((part) => Number(part));
2474
+ if (parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
2475
+ let value = 0;
2476
+ for (const part of parts) {
2477
+ value = value << 8 | part;
2478
+ }
2479
+ value <<= (4 - parts.length) * 8;
2480
+ return `${value >>> 24 & 255}.${value >>> 16 & 255}.${value >>> 8 & 255}.${value & 255}`;
2481
+ }
2482
+ function canonicalHostnameForBlocklist(hostname) {
2483
+ const normalized = normalizeHostname(hostname);
2484
+ return expandIpv4Literal(normalized) ?? normalized;
2485
+ }
2486
+ function isIpv4MappedAddress(hostname) {
2487
+ const match = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
2488
+ return match?.[1] ?? null;
2489
+ }
2490
+ function isLoopbackHost(hostname) {
2491
+ const ipv4Mapped = isIpv4MappedAddress(hostname);
2492
+ if (ipv4Mapped) return isLoopbackHost(ipv4Mapped);
2493
+ return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) || hostname === "::1" || hostname === "0.0.0.0";
2494
+ }
2495
+ function isIpv6UniqueLocalHost(hostname) {
2496
+ return /^f[cd][0-9a-f]{0,2}:/i.test(hostname);
2497
+ }
2498
+ function isLinkLocalOrMetadataHost(hostname) {
2499
+ if (hostname === "169.254.169.254") return true;
2500
+ if (/^169\.254\./.test(hostname)) return true;
2501
+ if (/^fe80:/i.test(hostname)) return true;
2502
+ return false;
2503
+ }
2504
+ function isRfc1918Host(hostname) {
2505
+ const ipv4Mapped = isIpv4MappedAddress(hostname);
2506
+ if (ipv4Mapped) return isRfc1918Host(ipv4Mapped);
2507
+ if (/^10\./.test(hostname)) return true;
2508
+ if (/^192\.168\./.test(hostname)) return true;
2509
+ const parts = hostname.split(".").map(Number);
2510
+ if (parts.length === 4 && parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
2511
+ return false;
2512
+ }
2513
+ function isBlockedHost(hostname, allowedHosts) {
2514
+ const normalized = normalizeHostname(hostname);
2515
+ const canonical = canonicalHostnameForBlocklist(hostname);
2516
+ if (allowedHosts?.some((host) => {
2517
+ const allowedNormalized = normalizeHostname(host);
2518
+ return canonicalHostnameForBlocklist(host) === canonical || allowedNormalized === normalized;
2519
+ })) {
2520
+ return false;
2521
+ }
2522
+ if (!isProductionEmbedBuild()) return false;
2523
+ return isLoopbackHost(canonical) || isLinkLocalOrMetadataHost(canonical) || isRfc1918Host(canonical) || isIpv6UniqueLocalHost(normalized);
2524
+ }
2525
+ function resolveAllowedUrl(src, options) {
2526
+ const trimmed = src.trim();
2527
+ if (!trimmed) return null;
2528
+ try {
2529
+ const base = typeof window !== "undefined" ? window.location.href : "https://example.com/";
2530
+ const url = new URL(trimmed, base);
2531
+ if (!allowedEmbedSchemes().has(url.protocol)) return null;
2532
+ if (isBlockedHost(url.hostname, options?.allowedHosts)) return null;
2533
+ if (typeof window !== "undefined") {
2534
+ const pageOrigin = window.location.origin;
2535
+ const isAbsolute = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed) || trimmed.startsWith("//");
2536
+ if (!isAbsolute && url.origin !== pageOrigin) return null;
2537
+ if (trimmed.startsWith("//") && url.origin !== pageOrigin) return null;
2538
+ }
2539
+ url.username = "";
2540
+ url.password = "";
2541
+ return url.href;
2542
+ } catch {
2543
+ return null;
2544
+ }
2545
+ }
2546
+ function resolveEmbedSrc(src, options) {
2547
+ return resolveAllowedUrl(src, options);
2548
+ }
2549
+ function resolveMediaSrc(src, options) {
2550
+ if (src === void 0) return null;
2551
+ return resolveAllowedUrl(src, options);
2552
+ }
2553
+ function buildEmbedSandbox(allow, options) {
2554
+ const tokens = /* @__PURE__ */ new Set([DEFAULT_SANDBOX]);
2555
+ if (allow) {
2556
+ for (const raw of allow.split(/\s+/)) {
2557
+ const token = raw.trim();
2558
+ if (!token || BLOCKED_SANDBOX_TOKENS.has(token)) continue;
2559
+ if (ALLOWED_SANDBOX_TOKENS.has(token)) tokens.add(token);
2560
+ }
2561
+ }
2562
+ if (options?.restrictPopupsInProduction !== false && isProductionEmbedBuild()) {
2563
+ tokens.delete("allow-popups");
2564
+ }
2565
+ return [...tokens].join(" ");
2566
+ }
2567
+ function telemetryEmbedSrc(src) {
2568
+ try {
2569
+ const url = new URL(src);
2570
+ url.username = "";
2571
+ url.password = "";
2572
+ url.search = "";
2573
+ url.hash = "";
2574
+ return `${url.origin}${url.pathname}`;
2575
+ } catch {
2576
+ return src;
2577
+ }
2578
+ }
2579
+ function resolveEmbedAspectRatio(aspectRatio) {
2580
+ if (!aspectRatio) return void 0;
2581
+ const trimmed = aspectRatio.trim();
2582
+ if (!/^\d+(\.\d+)?\s*\/\s*\d+(\.\d+)?$/.test(trimmed)) return void 0;
2583
+ const [numRaw, denRaw] = trimmed.split("/").map((part) => part.trim());
2584
+ const num = Number(numRaw);
2585
+ const den = Number(denRaw);
2586
+ if (!Number.isFinite(num) || !Number.isFinite(den) || num <= 0 || den <= 0) return void 0;
2587
+ return trimmed;
2588
+ }
2589
+
2025
2590
  // src/blocks/Image.tsx
2026
2591
  var import_jsx_runtime14 = require("react/jsx-runtime");
2027
2592
  function Image(props) {
2593
+ const { config } = useLessonkit();
2594
+ const resolvedSrc = resolveMediaSrc(props.src, {
2595
+ allowedHosts: config.embed?.allowedHosts
2596
+ });
2597
+ if (!resolvedSrc) {
2598
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
2599
+ "figure",
2600
+ {
2601
+ "data-lk-block-id": props.blockId,
2602
+ "data-testid": props.blockId ? `image-${props.blockId}` : "image",
2603
+ children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("p", { role: "alert", children: "This image URL is not allowed." })
2604
+ }
2605
+ );
2606
+ }
2028
2607
  return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
2029
2608
  "img",
2030
2609
  {
2031
- src: props.src,
2610
+ src: resolvedSrc,
2032
2611
  alt: props.alt,
2033
2612
  "data-lk-block-id": props.blockId,
2034
2613
  "data-testid": props.blockId ? `image-${props.blockId}` : "image",
@@ -2042,10 +2621,21 @@ setLessonkitBlockType(Image, "Image");
2042
2621
  var import_react24 = require("react");
2043
2622
  var import_jsx_runtime15 = require("react/jsx-runtime");
2044
2623
  function Video(props) {
2624
+ const { config } = useLessonkit();
2045
2625
  const blockId = (0, import_react24.useMemo)(
2046
2626
  () => normalizeComponentId(props.blockId, "blockId"),
2047
2627
  [props.blockId]
2048
2628
  );
2629
+ const mediaOptions = { allowedHosts: config.embed?.allowedHosts };
2630
+ const resolvedSrc = resolveMediaSrc(props.src, mediaOptions);
2631
+ const resolvedPoster = resolveMediaSrc(props.poster, mediaOptions);
2632
+ const resolvedCaptions = resolveMediaSrc(props.captions, mediaOptions);
2633
+ if (!resolvedSrc) {
2634
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("section", { "aria-label": props.title ?? "Video", "data-lk-block-id": blockId, "data-testid": "video", children: [
2635
+ props.title ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("h3", { "data-testid": "video-title", children: props.title }) : null,
2636
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("p", { role: "alert", "data-testid": "video-blocked", children: "This video URL is not allowed." })
2637
+ ] });
2638
+ }
2049
2639
  return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("section", { "aria-label": props.title ?? "Video", "data-lk-block-id": blockId, "data-testid": "video", children: [
2050
2640
  props.title ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("h3", { "data-testid": "video-title", children: props.title }) : null,
2051
2641
  /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
@@ -2053,11 +2643,20 @@ function Video(props) {
2053
2643
  {
2054
2644
  controls: true,
2055
2645
  preload: "metadata",
2056
- poster: props.poster,
2057
- src: props.src,
2646
+ poster: resolvedPoster ?? void 0,
2647
+ src: resolvedSrc,
2058
2648
  "data-testid": "video-player",
2059
2649
  style: { maxWidth: "100%" },
2060
- children: props.captions ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("track", { kind: "captions", src: props.captions, srcLang: "en", label: "Captions", default: true }) : null
2650
+ children: resolvedCaptions ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
2651
+ "track",
2652
+ {
2653
+ kind: "captions",
2654
+ src: resolvedCaptions,
2655
+ srcLang: "en",
2656
+ label: "Captions",
2657
+ default: true
2658
+ }
2659
+ ) : null
2061
2660
  }
2062
2661
  )
2063
2662
  ] });
@@ -2182,15 +2781,20 @@ var InteractiveBookInner = (0, import_react26.forwardRef)(
2182
2781
  }
2183
2782
  );
2184
2783
  var InteractiveBook = (0, import_react26.forwardRef)(function InteractiveBook2(props, ref) {
2185
- const blockId = (0, import_react26.useMemo)(
2186
- () => normalizeComponentId(props.blockId, "blockId"),
2187
- [props.blockId]
2188
- );
2189
2784
  const pages = import_react26.default.Children.toArray(props.children).filter(
2190
2785
  import_react26.default.isValidElement
2191
2786
  );
2192
2787
  const { config, storage } = useLessonkit();
2193
2788
  const persistEnabled = config.session?.persistCompoundState !== false;
2789
+ requireCompoundBlockIdWhenPersisting({
2790
+ persistEnabled,
2791
+ blockId: props.blockId,
2792
+ componentName: "InteractiveBook"
2793
+ });
2794
+ const blockId = (0, import_react26.useMemo)(
2795
+ () => normalizeComponentId(props.blockId, "blockId"),
2796
+ [props.blockId]
2797
+ );
2194
2798
  const initialIndex = useCompoundInitialIndex({
2195
2799
  courseId: config.courseId,
2196
2800
  compoundId: blockId,
@@ -2414,15 +3018,20 @@ var SlideDeckInner = (0, import_react29.forwardRef)(function SlideDeckInner2(pro
2414
3018
  );
2415
3019
  });
2416
3020
  var SlideDeck = (0, import_react29.forwardRef)(function SlideDeck2(props, ref) {
2417
- const blockId = (0, import_react29.useMemo)(
2418
- () => normalizeComponentId(props.blockId, "blockId"),
2419
- [props.blockId]
2420
- );
2421
3021
  const slides = import_react29.default.Children.toArray(props.children).filter(
2422
3022
  import_react29.default.isValidElement
2423
3023
  );
2424
3024
  const { config, storage } = useLessonkit();
2425
3025
  const persistEnabled = config.session?.persistCompoundState !== false;
3026
+ requireCompoundBlockIdWhenPersisting({
3027
+ persistEnabled,
3028
+ blockId: props.blockId,
3029
+ componentName: "SlideDeck"
3030
+ });
3031
+ const blockId = (0, import_react29.useMemo)(
3032
+ () => normalizeComponentId(props.blockId, "blockId"),
3033
+ [props.blockId]
3034
+ );
2426
3035
  const initialIndex = useCompoundInitialIndex({
2427
3036
  courseId: config.courseId,
2428
3037
  compoundId: blockId,
@@ -2499,36 +3108,18 @@ setLessonkitBlockType(TimedCue, "TimedCue");
2499
3108
 
2500
3109
  // src/blocks/InteractiveVideo.tsx
2501
3110
  var import_react31 = __toESM(require("react"), 1);
2502
- var import_core19 = require("@lessonkit/core");
2503
-
2504
- // src/compound/useCompoundVideoShell.ts
2505
- var import_core18 = require("@lessonkit/core");
2506
- var IV_META_KEY = "__lk_iv__";
2507
- function readInteractiveVideoMeta(childStates) {
2508
- const raw = childStates[IV_META_KEY];
2509
- if (!raw || typeof raw !== "object") return null;
2510
- const currentTime = typeof raw.currentTime === "number" ? raw.currentTime : 0;
2511
- const completedCueIndices = Array.isArray(raw.completedCueIndices) ? raw.completedCueIndices.filter((n) => typeof n === "number") : [];
2512
- return { currentTime, completedCueIndices };
2513
- }
2514
- function mergeVideoMetaIntoState(state, meta) {
2515
- return {
2516
- ...state,
2517
- childStates: {
2518
- ...state.childStates,
2519
- [IV_META_KEY]: meta
2520
- }
2521
- };
2522
- }
2523
-
2524
- // src/blocks/InteractiveVideo.tsx
3111
+ var import_core20 = require("@lessonkit/core");
2525
3112
  var import_jsx_runtime21 = require("react/jsx-runtime");
3113
+ function sortCuesByTime(cues) {
3114
+ return [...cues].sort((a, b) => (a.props.atSeconds ?? 0) - (b.props.atSeconds ?? 0));
3115
+ }
2526
3116
  function loadVideoMeta(storage, courseId, blockId, enabled) {
2527
- if (!enabled || !courseId) return { currentTime: 0, completedCueIndices: [] };
2528
- const saved = (0, import_core19.loadCompoundState)(storage, courseId, blockId);
2529
- if (!saved) return { currentTime: 0, completedCueIndices: [] };
3117
+ const empty = { currentTime: 0, completedCueIndices: [], firedCueIndices: [] };
3118
+ if (!enabled || !courseId) return empty;
3119
+ const saved = (0, import_core20.loadCompoundState)(storage, courseId, blockId);
3120
+ if (!saved) return empty;
2530
3121
  const meta = readInteractiveVideoMeta(saved.childStates);
2531
- return meta ?? { currentTime: 0, completedCueIndices: [] };
3122
+ return meta ?? empty;
2532
3123
  }
2533
3124
  function getCueChildCheckId(cue) {
2534
3125
  const child = import_react31.default.Children.only(cue.props.children);
@@ -2545,29 +3136,59 @@ var InteractiveVideoInner = (0, import_react31.forwardRef)(function InteractiveV
2545
3136
  validateCompoundChildren("InteractiveVideo", cues);
2546
3137
  const { config, track, storage } = useLessonkit();
2547
3138
  const lessonId = useEnclosingLessonId();
3139
+ const mediaOptions = { allowedHosts: config.embed?.allowedHosts };
3140
+ const resolvedSrc = resolveMediaSrc(props.src, mediaOptions);
3141
+ const resolvedPoster = resolveMediaSrc(props.poster, mediaOptions);
3142
+ const resolvedCaptions = resolveMediaSrc(props.captions, mediaOptions);
2548
3143
  const videoRef = (0, import_react31.useRef)(null);
3144
+ const lastKnownTimeRef = (0, import_react31.useRef)(initialMeta.currentTime);
2549
3145
  const completedCuesRef = (0, import_react31.useRef)(new Set(initialMeta.completedCueIndices));
2550
3146
  const [completedCues, setCompletedCues] = (0, import_react31.useState)(
2551
3147
  () => new Set(initialMeta.completedCueIndices)
2552
3148
  );
2553
3149
  const [overlayActive, setOverlayActive] = (0, import_react31.useState)(false);
2554
- const firedCuesRef = (0, import_react31.useRef)(new Set(initialMeta.completedCueIndices));
2555
- const resumeOverlayCheckedRef = (0, import_react31.useRef)(false);
2556
- const sortedCues = (0, import_react31.useMemo)(
2557
- () => [...cues].sort((a, b) => (a.props.atSeconds ?? 0) - (b.props.atSeconds ?? 0)),
2558
- [cues]
3150
+ const firedCuesRef = (0, import_react31.useRef)(
3151
+ new Set(
3152
+ initialMeta.firedCueIndices.length > 0 ? initialMeta.firedCueIndices : initialMeta.completedCueIndices
3153
+ )
2559
3154
  );
3155
+ const resumeOverlayCheckedRef = (0, import_react31.useRef)(false);
3156
+ const [persistTrigger, setPersistTrigger] = (0, import_react31.useState)(0);
3157
+ const lastPersistTimeRef = (0, import_react31.useRef)(0);
3158
+ const sortedCues = cues;
2560
3159
  (0, import_react31.useEffect)(() => {
2561
3160
  completedCuesRef.current = completedCues;
2562
3161
  }, [completedCues]);
2563
3162
  const transformState = (0, import_react31.useCallback)(
2564
- (state) => mergeVideoMetaIntoState(state, {
2565
- currentTime: videoRef.current?.currentTime ?? initialMeta.currentTime,
2566
- completedCueIndices: [...completedCuesRef.current]
2567
- }),
2568
- [initialMeta.currentTime]
3163
+ (state) => {
3164
+ const liveTime = videoRef.current?.currentTime;
3165
+ const currentTime = Math.max(
3166
+ lastKnownTimeRef.current,
3167
+ typeof liveTime === "number" && Number.isFinite(liveTime) ? liveTime : 0
3168
+ );
3169
+ return mergeVideoMetaIntoState(state, {
3170
+ currentTime,
3171
+ completedCueIndices: [...completedCuesRef.current],
3172
+ firedCueIndices: [...firedCuesRef.current]
3173
+ });
3174
+ },
3175
+ []
2569
3176
  );
2570
- const { ctx } = useCompoundShell({
3177
+ const applyVideoMetaFromState = (0, import_react31.useCallback)((state) => {
3178
+ const meta = readInteractiveVideoMeta(state.childStates);
3179
+ if (!meta) return;
3180
+ lastKnownTimeRef.current = meta.currentTime;
3181
+ completedCuesRef.current = new Set(meta.completedCueIndices);
3182
+ firedCuesRef.current = new Set(
3183
+ meta.firedCueIndices.length > 0 ? meta.firedCueIndices : meta.completedCueIndices
3184
+ );
3185
+ setCompletedCues(new Set(meta.completedCueIndices));
3186
+ const video = videoRef.current;
3187
+ if (video && meta.currentTime > 0) {
3188
+ video.currentTime = meta.currentTime;
3189
+ }
3190
+ }, []);
3191
+ const { visibleIndex, ctx } = useCompoundShell({
2571
3192
  courseId: config.courseId,
2572
3193
  compoundId: blockId,
2573
3194
  pageCount: sortedCues.length,
@@ -2576,9 +3197,11 @@ var InteractiveVideoInner = (0, import_react31.forwardRef)(function InteractiveV
2576
3197
  persistEnabled,
2577
3198
  ref,
2578
3199
  storage,
2579
- transformState
3200
+ transformState,
3201
+ persistTrigger,
3202
+ onCompoundResume: applyVideoMetaFromState
2580
3203
  });
2581
- const activeCue = sortedCues[index];
3204
+ const activeCue = sortedCues[visibleIndex];
2582
3205
  const cueCanContinue = (0, import_react31.useCallback)(
2583
3206
  (cue) => {
2584
3207
  if (!cue || !cueRequiresAnswer(cue)) return true;
@@ -2599,12 +3222,12 @@ var InteractiveVideoInner = (0, import_react31.forwardRef)(function InteractiveV
2599
3222
  (0, import_react31.useEffect)(() => {
2600
3223
  if (resumeOverlayCheckedRef.current || sortedCues.length === 0) return;
2601
3224
  resumeOverlayCheckedRef.current = true;
2602
- const hasSavedProgress = initialMeta.currentTime > 0 || initialMeta.completedCueIndices.length > 0 || persistEnabled && config.courseId && (0, import_core19.loadCompoundState)(storage, config.courseId, blockId) !== null;
3225
+ const hasSavedProgress = initialMeta.currentTime > 0 || initialMeta.completedCueIndices.length > 0 || persistEnabled && config.courseId && (0, import_core20.loadCompoundState)(storage, config.courseId, blockId) !== null;
2603
3226
  if (!hasSavedProgress) return;
2604
3227
  const video = videoRef.current;
2605
3228
  if (!video) return;
2606
- const cue = sortedCues[index];
2607
- if (!cue || completedCues.has(index)) return;
3229
+ const cue = sortedCues[visibleIndex];
3230
+ if (!cue || completedCues.has(visibleIndex)) return;
2608
3231
  setOverlayActive(true);
2609
3232
  video.pause();
2610
3233
  const at = cue.props.atSeconds ?? 0;
@@ -2616,6 +3239,7 @@ var InteractiveVideoInner = (0, import_react31.forwardRef)(function InteractiveV
2616
3239
  completedCues,
2617
3240
  config.courseId,
2618
3241
  index,
3242
+ visibleIndex,
2619
3243
  initialMeta.completedCueIndices.length,
2620
3244
  initialMeta.currentTime,
2621
3245
  persistEnabled,
@@ -2655,6 +3279,12 @@ var InteractiveVideoInner = (0, import_react31.forwardRef)(function InteractiveV
2655
3279
  const video = videoRef.current;
2656
3280
  if (!video || overlayActive) return;
2657
3281
  const t = video.currentTime;
3282
+ lastKnownTimeRef.current = Math.max(lastKnownTimeRef.current, t);
3283
+ const now = Date.now();
3284
+ if (now - lastPersistTimeRef.current >= 5e3) {
3285
+ lastPersistTimeRef.current = now;
3286
+ setPersistTrigger((n) => n + 1);
3287
+ }
2658
3288
  const blockSeek = mandatoryIncompleteBefore(t);
2659
3289
  if (blockSeek !== null && t > blockSeek + 0.5) {
2660
3290
  video.currentTime = blockSeek;
@@ -2670,10 +3300,10 @@ var InteractiveVideoInner = (0, import_react31.forwardRef)(function InteractiveV
2670
3300
  }
2671
3301
  };
2672
3302
  const completeCue = () => {
2673
- const cue = sortedCues[index];
3303
+ const cue = sortedCues[visibleIndex];
2674
3304
  if (!cue || !cueCanContinue(cue)) return;
2675
3305
  setCompletedCues((prev) => {
2676
- const next = /* @__PURE__ */ new Set([...prev, index]);
3306
+ const next = /* @__PURE__ */ new Set([...prev, visibleIndex]);
2677
3307
  completedCuesRef.current = next;
2678
3308
  return next;
2679
3309
  });
@@ -2683,7 +3313,7 @@ var InteractiveVideoInner = (0, import_react31.forwardRef)(function InteractiveV
2683
3313
  "video_segment_completed",
2684
3314
  {
2685
3315
  blockId,
2686
- segmentIndex: index,
3316
+ segmentIndex: visibleIndex,
2687
3317
  atSeconds: cue.props.atSeconds ?? 0,
2688
3318
  segmentLabel: cue.props.label
2689
3319
  },
@@ -2702,12 +3332,12 @@ var InteractiveVideoInner = (0, import_react31.forwardRef)(function InteractiveV
2702
3332
  " ",
2703
3333
  Array.from(ctx.getHandles().values()).reduce((s, h) => s + h.getMaxScore(), 0)
2704
3334
  ] }) : null,
2705
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("div", { style: { position: "relative" }, children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3335
+ !resolvedSrc ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("p", { role: "alert", "data-testid": "interactive-video-blocked", children: "This video URL is not allowed." }) : /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("div", { style: { position: "relative" }, children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
2706
3336
  "video",
2707
3337
  {
2708
3338
  ref: videoRef,
2709
- src: props.src,
2710
- poster: props.poster,
3339
+ src: resolvedSrc,
3340
+ poster: resolvedPoster ?? void 0,
2711
3341
  controls: true,
2712
3342
  "data-testid": "interactive-video-player",
2713
3343
  onTimeUpdate,
@@ -2719,13 +3349,22 @@ var InteractiveVideoInner = (0, import_react31.forwardRef)(function InteractiveV
2719
3349
  video.currentTime = blockSeek;
2720
3350
  }
2721
3351
  },
2722
- children: props.captions ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("track", { kind: "captions", src: props.captions, srcLang: "en", label: "Captions", default: true }) : null
3352
+ children: resolvedCaptions ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3353
+ "track",
3354
+ {
3355
+ kind: "captions",
3356
+ src: resolvedCaptions,
3357
+ srcLang: "en",
3358
+ label: "Captions",
3359
+ default: true
3360
+ }
3361
+ ) : null
2723
3362
  }
2724
3363
  ) }),
2725
3364
  /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("div", { "data-testid": "interactive-video-cues", children: sortedCues.map(
2726
3365
  (cue, i) => import_react31.default.cloneElement(cue, {
2727
3366
  key: cue.key ?? i,
2728
- hidden: !overlayActive || i !== index,
3367
+ hidden: !overlayActive || i !== visibleIndex,
2729
3368
  cueIndex: i,
2730
3369
  parentType: "InteractiveVideo"
2731
3370
  })
@@ -2748,15 +3387,21 @@ var InteractiveVideoInner = (0, import_react31.forwardRef)(function InteractiveV
2748
3387
  });
2749
3388
  var InteractiveVideo = (0, import_react31.forwardRef)(
2750
3389
  function InteractiveVideo2(props, ref) {
2751
- const blockId = (0, import_react31.useMemo)(
2752
- () => normalizeComponentId(props.blockId, "blockId"),
2753
- [props.blockId]
2754
- );
2755
3390
  const cues = import_react31.default.Children.toArray(props.children).filter(
2756
3391
  import_react31.default.isValidElement
2757
3392
  );
3393
+ const sortedCues = (0, import_react31.useMemo)(() => sortCuesByTime(cues), [cues]);
2758
3394
  const { config, storage } = useLessonkit();
2759
3395
  const persistEnabled = config.session?.persistCompoundState !== false;
3396
+ requireCompoundBlockIdWhenPersisting({
3397
+ persistEnabled,
3398
+ blockId: props.blockId,
3399
+ componentName: "InteractiveVideo"
3400
+ });
3401
+ const blockId = (0, import_react31.useMemo)(
3402
+ () => normalizeComponentId(props.blockId, "blockId"),
3403
+ [props.blockId]
3404
+ );
2760
3405
  const initialMeta = (0, import_react31.useMemo)(
2761
3406
  () => loadVideoMeta(storage, config.courseId, blockId, persistEnabled),
2762
3407
  [storage, config.courseId, blockId, persistEnabled]
@@ -2764,7 +3409,7 @@ var InteractiveVideo = (0, import_react31.forwardRef)(
2764
3409
  const initialIndex = useCompoundInitialIndex({
2765
3410
  courseId: config.courseId,
2766
3411
  compoundId: blockId,
2767
- pageCount: cues.length,
3412
+ pageCount: sortedCues.length,
2768
3413
  persistEnabled,
2769
3414
  storage
2770
3415
  });
@@ -2772,14 +3417,14 @@ var InteractiveVideo = (0, import_react31.forwardRef)(
2772
3417
  const setIndexStable = (0, import_react31.useCallback)((i) => setIndex(i), []);
2773
3418
  (0, import_react31.useEffect)(() => {
2774
3419
  setIndex(initialIndex);
2775
- }, [config.courseId, blockId, initialIndex]);
3420
+ }, [config.courseId, blockId, initialIndex, sortedCues.length]);
2776
3421
  return /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(CompoundProvider, { activePageIndex: index, onActivePageIndexChange: setIndexStable, children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
2777
3422
  InteractiveVideoInner,
2778
3423
  {
2779
3424
  ...props,
2780
3425
  ref,
2781
3426
  blockId,
2782
- cues,
3427
+ cues: sortedCues,
2783
3428
  index,
2784
3429
  setIndex,
2785
3430
  persistEnabled,
@@ -2792,10 +3437,18 @@ setLessonkitBlockType(InteractiveVideo, "InteractiveVideo");
2792
3437
 
2793
3438
  // src/blocks/Summary.tsx
2794
3439
  var import_react32 = require("react");
3440
+
3441
+ // src/assessment/shouldReplayResumeTelemetry.ts
3442
+ function shouldReplayResumeTelemetry(config) {
3443
+ return config?.tracking?.replayResumeEvents === true;
3444
+ }
3445
+
3446
+ // src/blocks/Summary.tsx
2795
3447
  var import_jsx_runtime22 = require("react/jsx-runtime");
2796
3448
  var INTERACTION6 = "summary";
2797
3449
  function SummaryInner(props, ref) {
2798
3450
  const checkId = (0, import_react32.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
3451
+ const { config } = useLessonkit();
2799
3452
  const assessment = useAssessmentState(props.enclosingLessonId);
2800
3453
  const [selectedIndices, setSelectedIndices] = (0, import_react32.useState)([]);
2801
3454
  const [passed, setPassed] = (0, import_react32.useState)(false);
@@ -2823,7 +3476,7 @@ function SummaryInner(props, ref) {
2823
3476
  const handle = (0, import_react32.useMemo)(
2824
3477
  () => buildAssessmentHandle({
2825
3478
  checkId,
2826
- getScore: () => passed ? score : 0,
3479
+ getScore: () => score,
2827
3480
  getMaxScore: () => maxScore,
2828
3481
  getAnswerGiven: () => selectedIndices.length > 0,
2829
3482
  resetTask: reset,
@@ -2834,7 +3487,7 @@ function SummaryInner(props, ref) {
2834
3487
  interactionType: INTERACTION6,
2835
3488
  response: selected,
2836
3489
  correct: passedThreshold,
2837
- score: passed ? score : 0,
3490
+ score,
2838
3491
  maxScore
2839
3492
  }),
2840
3493
  getCurrentState: () => ({ selectedIndices, passed, checked }),
@@ -2844,34 +3497,48 @@ function SummaryInner(props, ref) {
2844
3497
  nextIndices = [...state.selectedIndices];
2845
3498
  } else if (Array.isArray(state.selected)) {
2846
3499
  const legacy = state.selected;
3500
+ if (isDevEnvironment()) {
3501
+ const seen = /* @__PURE__ */ new Set();
3502
+ for (const text of props.statements) {
3503
+ if (seen.has(text)) {
3504
+ console.warn(
3505
+ "[lessonkit] Summary: duplicate statement strings; legacy selected resume may be ambiguous",
3506
+ text
3507
+ );
3508
+ break;
3509
+ }
3510
+ seen.add(text);
3511
+ }
3512
+ }
2847
3513
  nextIndices = legacy.map((text) => props.statements.indexOf(text)).filter((i) => i >= 0);
2848
3514
  }
2849
3515
  setSelectedIndices(nextIndices);
2850
3516
  const nextSelected = nextIndices.map((i) => props.statements[i] ?? "");
2851
3517
  const nextIsCorrect = nextSelected.length === props.correct.length && nextSelected.every((s, i) => s === props.correct[i]);
2852
3518
  const nextScore = nextIsCorrect ? maxScore : 0;
2853
- readBooleanStateField(state, "passed", (value) => {
2854
- setPassed(value);
2855
- completedRef.current = value;
2856
- if (value) {
2857
- if (!telemetryReplayedRef.current) {
2858
- telemetryReplayedRef.current = true;
2859
- assessment.answer({
2860
- checkId,
2861
- interactionType: INTERACTION6,
2862
- response: nextSelected,
2863
- correct: true
2864
- });
2865
- assessment.complete({
2866
- checkId,
2867
- interactionType: INTERACTION6,
2868
- score: nextScore,
2869
- maxScore,
2870
- passingScore: props.passingScore ?? maxScore
2871
- });
2872
- }
2873
- }
2874
- });
3519
+ const nextPassedThreshold = meetsPassingThreshold(
3520
+ nextScore,
3521
+ maxScore,
3522
+ props.passingScore
3523
+ );
3524
+ setPassed(nextPassedThreshold);
3525
+ completedRef.current = nextPassedThreshold;
3526
+ if (nextPassedThreshold && !telemetryReplayedRef.current && shouldReplayResumeTelemetry(config)) {
3527
+ telemetryReplayedRef.current = true;
3528
+ assessment.answer({
3529
+ checkId,
3530
+ interactionType: INTERACTION6,
3531
+ response: nextSelected,
3532
+ correct: nextPassedThreshold
3533
+ });
3534
+ assessment.complete({
3535
+ checkId,
3536
+ interactionType: INTERACTION6,
3537
+ score: nextScore,
3538
+ maxScore,
3539
+ passingScore: props.passingScore ?? maxScore
3540
+ });
3541
+ }
2875
3542
  readBooleanStateField(state, "checked", setChecked);
2876
3543
  }
2877
3544
  }),
@@ -2879,14 +3546,16 @@ function SummaryInner(props, ref) {
2879
3546
  assessment,
2880
3547
  checkId,
2881
3548
  checked,
3549
+ config,
2882
3550
  maxScore,
2883
3551
  passed,
2884
3552
  passedThreshold,
3553
+ props.correct,
2885
3554
  props.passingScore,
2886
3555
  props.statements,
2887
3556
  score,
2888
3557
  selected,
2889
- selectedIndices.length
3558
+ selectedIndices
2890
3559
  ]
2891
3560
  );
2892
3561
  useAssessmentHandleRegistration(checkId, handle, ref);
@@ -2909,9 +3578,9 @@ function SummaryInner(props, ref) {
2909
3578
  response: selected,
2910
3579
  correct: passedThreshold
2911
3580
  });
2912
- if (passedThreshold && !completedRef.current) {
3581
+ if ((passedThreshold || props.enableRetry === false) && !completedRef.current) {
2913
3582
  completedRef.current = true;
2914
- setPassed(true);
3583
+ if (passedThreshold) setPassed(true);
2915
3584
  assessment.complete({
2916
3585
  checkId,
2917
3586
  interactionType: INTERACTION6,
@@ -2989,8 +3658,34 @@ function buildDeck(pairs) {
2989
3658
  );
2990
3659
  return shuffleCards(cards);
2991
3660
  }
3661
+ function rebuildCardsFromKeys(pairs, cardKeys) {
3662
+ const pairMap = new Map(pairs.map((pair) => [pair.id, pair]));
3663
+ if (cardKeys.length !== pairs.length * 2) return null;
3664
+ const seen = /* @__PURE__ */ new Set();
3665
+ const cards = [];
3666
+ for (const cardKey of cardKeys) {
3667
+ if (seen.has(cardKey)) return null;
3668
+ seen.add(cardKey);
3669
+ const match = /^(.+)-([01])$/.exec(cardKey);
3670
+ if (!match) return null;
3671
+ const pairId = match[1];
3672
+ const copy = Number(match[2]);
3673
+ if (copy !== 0 && copy !== 1) return null;
3674
+ const pair = pairMap.get(pairId);
3675
+ if (!pair) return null;
3676
+ cards.push({
3677
+ cardKey,
3678
+ pairId: pair.id,
3679
+ label: pair.label,
3680
+ imageSrc: pair.imageSrc
3681
+ });
3682
+ }
3683
+ return cards;
3684
+ }
2992
3685
  function ImagePairingInner(props, ref) {
2993
3686
  const checkId = (0, import_react33.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
3687
+ const { config } = useLessonkit();
3688
+ const mediaOptions = { allowedHosts: config.embed?.allowedHosts };
2994
3689
  const assessment = useAssessmentState(props.enclosingLessonId);
2995
3690
  const pairsKey = props.pairs.map((p) => p.id).join("\0");
2996
3691
  const [cards, setCards] = (0, import_react33.useState)(() => buildDeck(props.pairs));
@@ -2998,9 +3693,15 @@ function ImagePairingInner(props, ref) {
2998
3693
  const [revealed, setRevealed] = (0, import_react33.useState)(() => /* @__PURE__ */ new Set());
2999
3694
  const [keyboardSelection, setKeyboardSelection] = (0, import_react33.useState)(null);
3000
3695
  const [passed, setPassed] = (0, import_react33.useState)(false);
3696
+ const [submitted, setSubmitted] = (0, import_react33.useState)(false);
3001
3697
  const completedRef = (0, import_react33.useRef)(false);
3002
3698
  const telemetryReplayedRef = (0, import_react33.useRef)(false);
3699
+ const mismatchTimeoutRef = (0, import_react33.useRef)(null);
3003
3700
  const reset = () => {
3701
+ if (mismatchTimeoutRef.current !== null) {
3702
+ window.clearTimeout(mismatchTimeoutRef.current);
3703
+ mismatchTimeoutRef.current = null;
3704
+ }
3004
3705
  completedRef.current = false;
3005
3706
  telemetryReplayedRef.current = false;
3006
3707
  setCards(buildDeck(props.pairs));
@@ -3008,10 +3709,19 @@ function ImagePairingInner(props, ref) {
3008
3709
  setRevealed(/* @__PURE__ */ new Set());
3009
3710
  setKeyboardSelection(null);
3010
3711
  setPassed(false);
3712
+ setSubmitted(false);
3011
3713
  };
3012
3714
  (0, import_react33.useEffect)(() => {
3013
3715
  reset();
3014
3716
  }, [checkId, pairsKey]);
3717
+ (0, import_react33.useEffect)(
3718
+ () => () => {
3719
+ if (mismatchTimeoutRef.current !== null) {
3720
+ window.clearTimeout(mismatchTimeoutRef.current);
3721
+ }
3722
+ },
3723
+ []
3724
+ );
3015
3725
  const totalPairs = props.pairs.length;
3016
3726
  const matchedCount = matched.size;
3017
3727
  const maxScore = totalPairs || 1;
@@ -3019,25 +3729,34 @@ function ImagePairingInner(props, ref) {
3019
3729
  const allMatched = totalPairs > 0 && matchedCount === totalPairs;
3020
3730
  const passedThreshold = meetsPassingThreshold(score, maxScore, props.passingScore);
3021
3731
  const completeIfReady = (nextMatched) => {
3022
- if (nextMatched.size === totalPairs && totalPairs > 0 && !completedRef.current) {
3023
- const finalScore = nextMatched.size;
3024
- const finalPassed = meetsPassingThreshold(finalScore, maxScore, props.passingScore);
3025
- completedRef.current = true;
3026
- setPassed(true);
3027
- assessment.answer({
3028
- checkId,
3029
- interactionType: INTERACTION7,
3030
- response: { matchedPairIds: [...nextMatched] },
3031
- correct: finalPassed
3032
- });
3033
- assessment.complete({
3034
- checkId,
3035
- interactionType: INTERACTION7,
3036
- score: finalScore,
3037
- maxScore,
3038
- passingScore: props.passingScore ?? maxScore
3039
- });
3040
- }
3732
+ if (totalPairs === 0 || completedRef.current) return;
3733
+ const finalScore = nextMatched.size;
3734
+ const finalPassed = meetsPassingThreshold(finalScore, maxScore, props.passingScore);
3735
+ if (!finalPassed && nextMatched.size < totalPairs) return;
3736
+ completeWithScore(nextMatched, finalScore, finalPassed);
3737
+ };
3738
+ const completeWithScore = (nextMatched, finalScore, finalPassed) => {
3739
+ if (completedRef.current) return;
3740
+ completedRef.current = true;
3741
+ setSubmitted(true);
3742
+ setPassed(finalPassed);
3743
+ assessment.answer({
3744
+ checkId,
3745
+ interactionType: INTERACTION7,
3746
+ response: { matchedPairIds: [...nextMatched] },
3747
+ correct: finalPassed
3748
+ });
3749
+ assessment.complete({
3750
+ checkId,
3751
+ interactionType: INTERACTION7,
3752
+ score: finalScore,
3753
+ maxScore,
3754
+ passingScore: props.passingScore ?? maxScore
3755
+ });
3756
+ };
3757
+ const finishAttempt = () => {
3758
+ if (completedRef.current || matchedCount === 0) return;
3759
+ completeWithScore(matched, matchedCount, passedThreshold);
3041
3760
  };
3042
3761
  const tryMatch = (firstKey, secondKey) => {
3043
3762
  if (firstKey === secondKey) return;
@@ -3054,7 +3773,11 @@ function ImagePairingInner(props, ref) {
3054
3773
  setRevealed(/* @__PURE__ */ new Set());
3055
3774
  setKeyboardSelection(null);
3056
3775
  } else {
3057
- window.setTimeout(() => {
3776
+ if (mismatchTimeoutRef.current !== null) {
3777
+ window.clearTimeout(mismatchTimeoutRef.current);
3778
+ }
3779
+ mismatchTimeoutRef.current = window.setTimeout(() => {
3780
+ mismatchTimeoutRef.current = null;
3058
3781
  setRevealed((prev) => {
3059
3782
  const next = new Set(prev);
3060
3783
  next.delete(firstKey);
@@ -3097,17 +3820,22 @@ function ImagePairingInner(props, ref) {
3097
3820
  checkId,
3098
3821
  interactionType: INTERACTION7,
3099
3822
  response: { matchedPairIds: [...matched] },
3100
- correct: allMatched && passedThreshold,
3823
+ correct: passedThreshold,
3101
3824
  score,
3102
3825
  maxScore
3103
3826
  }),
3104
3827
  getCurrentState: () => ({
3828
+ cardKeys: cards.map((card) => card.cardKey),
3105
3829
  matched: [...matched],
3106
3830
  revealed: [...revealed],
3107
3831
  keyboardSelection,
3108
3832
  passed
3109
3833
  }),
3110
3834
  resume: (state) => {
3835
+ if (Array.isArray(state.cardKeys)) {
3836
+ const restored = rebuildCardsFromKeys(props.pairs, state.cardKeys);
3837
+ if (restored) setCards(restored);
3838
+ }
3111
3839
  if (Array.isArray(state.matched)) setMatched(new Set(state.matched));
3112
3840
  if (Array.isArray(state.revealed)) setRevealed(new Set(state.revealed));
3113
3841
  const sel = state.keyboardSelection;
@@ -3115,15 +3843,20 @@ function ImagePairingInner(props, ref) {
3115
3843
  readBooleanStateField(state, "passed", (value) => {
3116
3844
  setPassed(value);
3117
3845
  completedRef.current = value;
3118
- if (value && !telemetryReplayedRef.current) {
3846
+ if (value && !telemetryReplayedRef.current && shouldReplayResumeTelemetry(config)) {
3119
3847
  telemetryReplayedRef.current = true;
3120
3848
  const matchedIds = Array.isArray(state.matched) ? state.matched : [...matched];
3121
3849
  const finalScore = matchedIds.length;
3850
+ const finalPassed = meetsPassingThreshold(
3851
+ finalScore,
3852
+ maxScore,
3853
+ props.passingScore
3854
+ );
3122
3855
  assessment.answer({
3123
3856
  checkId,
3124
3857
  interactionType: INTERACTION7,
3125
3858
  response: { matchedPairIds: matchedIds },
3126
- correct: true
3859
+ correct: finalPassed
3127
3860
  });
3128
3861
  assessment.complete({
3129
3862
  checkId,
@@ -3136,7 +3869,7 @@ function ImagePairingInner(props, ref) {
3136
3869
  });
3137
3870
  }
3138
3871
  }),
3139
- [allMatched, checkId, keyboardSelection, matched, matchedCount, maxScore, passed, passedThreshold, revealed, score]
3872
+ [allMatched, assessment, cards, checkId, config, keyboardSelection, matched, matchedCount, maxScore, passed, passedThreshold, props.pairs, props.passingScore, revealed, score]
3140
3873
  );
3141
3874
  useAssessmentHandleRegistration(checkId, handle, ref);
3142
3875
  return /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("section", { "aria-label": "Image Pairing", "data-lk-check-id": checkId, "data-testid": "image-pairing", children: [
@@ -3145,6 +3878,7 @@ function ImagePairingInner(props, ref) {
3145
3878
  const isMatched = matched.has(card.pairId);
3146
3879
  const isRevealed = isMatched || revealed.has(card.cardKey);
3147
3880
  const isSelected = keyboardSelection === card.cardKey;
3881
+ const resolvedCardSrc = resolveMediaSrc(card.imageSrc, mediaOptions);
3148
3882
  return /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
3149
3883
  "button",
3150
3884
  {
@@ -3161,7 +3895,14 @@ function ImagePairingInner(props, ref) {
3161
3895
  border: isSelected ? "2px solid currentColor" : "1px solid currentColor"
3162
3896
  },
3163
3897
  children: isRevealed ? /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)(import_jsx_runtime23.Fragment, { children: [
3164
- /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("img", { src: card.imageSrc, alt: card.label, style: { maxWidth: "5rem", maxHeight: "5rem" } }),
3898
+ resolvedCardSrc ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
3899
+ "img",
3900
+ {
3901
+ src: resolvedCardSrc,
3902
+ alt: card.label,
3903
+ style: { maxWidth: "5rem", maxHeight: "5rem" }
3904
+ }
3905
+ ) : /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("span", { "aria-hidden": "true", children: "!" }),
3165
3906
  /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("span", { className: "lk-visually-hidden", children: card.label })
3166
3907
  ] }) : "?"
3167
3908
  },
@@ -3174,6 +3915,7 @@ function ImagePairingInner(props, ref) {
3174
3915
  totalPairs,
3175
3916
  " pairs matched"
3176
3917
  ] }),
3918
+ props.enableRetry === false && matchedCount > 0 && !submitted ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("button", { type: "button", "data-testid": "image-pairing-finish", onClick: finishAttempt, children: "Submit" }) : null,
3177
3919
  props.enableRetry && passed ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("button", { type: "button", "data-testid": "image-pairing-retry", onClick: reset, children: "Try again" }) : null
3178
3920
  ] });
3179
3921
  }
@@ -3189,6 +3931,8 @@ var import_jsx_runtime24 = require("react/jsx-runtime");
3189
3931
  var INTERACTION8 = "imageSequencing";
3190
3932
  function ImageSequencingInner(props, ref) {
3191
3933
  const checkId = (0, import_react34.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
3934
+ const { config } = useLessonkit();
3935
+ const mediaOptions = { allowedHosts: config.embed?.allowedHosts };
3192
3936
  const assessment = useAssessmentState(props.enclosingLessonId);
3193
3937
  const imagesKey = props.images.map((i) => i.id).join("\0");
3194
3938
  const orderKey = props.correctOrder.join("\0");
@@ -3225,9 +3969,9 @@ function ImageSequencingInner(props, ref) {
3225
3969
  const handle = (0, import_react34.useMemo)(
3226
3970
  () => buildAssessmentHandle({
3227
3971
  checkId,
3228
- getScore: () => passed ? score : 0,
3972
+ getScore: () => score,
3229
3973
  getMaxScore: () => maxScore,
3230
- getAnswerGiven: () => order.length > 0,
3974
+ getAnswerGiven: () => checked,
3231
3975
  resetTask: reset,
3232
3976
  showSolutions: () => {
3233
3977
  },
@@ -3236,7 +3980,7 @@ function ImageSequencingInner(props, ref) {
3236
3980
  interactionType: INTERACTION8,
3237
3981
  response: order,
3238
3982
  correct: passedThreshold,
3239
- score: passed ? score : 0,
3983
+ score,
3240
3984
  maxScore
3241
3985
  }),
3242
3986
  getCurrentState: () => ({ order, passed, checked }),
@@ -3249,7 +3993,7 @@ function ImageSequencingInner(props, ref) {
3249
3993
  readBooleanStateField(state, "passed", (value) => {
3250
3994
  setPassed(value);
3251
3995
  completedRef.current = value;
3252
- if (value && !telemetryReplayedRef.current) {
3996
+ if (value && !telemetryReplayedRef.current && shouldReplayResumeTelemetry(config)) {
3253
3997
  telemetryReplayedRef.current = true;
3254
3998
  const nextIsCorrect = nextOrder.every((id, i) => id === props.correctOrder[i]);
3255
3999
  const nextScore = nextIsCorrect ? maxScore : 0;
@@ -3271,7 +4015,7 @@ function ImageSequencingInner(props, ref) {
3271
4015
  readBooleanStateField(state, "checked", setChecked);
3272
4016
  }
3273
4017
  }),
3274
- [checkId, checked, maxScore, order, passed, passedThreshold, score]
4018
+ [assessment, checkId, checked, config, maxScore, order, passed, passedThreshold, props.correctOrder, props.passingScore, score]
3275
4019
  );
3276
4020
  useAssessmentHandleRegistration(checkId, handle, ref);
3277
4021
  const check = () => {
@@ -3282,9 +4026,9 @@ function ImageSequencingInner(props, ref) {
3282
4026
  response: order,
3283
4027
  correct: passedThreshold
3284
4028
  });
3285
- if (passedThreshold && !completedRef.current) {
4029
+ if ((passedThreshold || props.enableRetry === false) && !completedRef.current) {
3286
4030
  completedRef.current = true;
3287
- setPassed(true);
4031
+ if (passedThreshold) setPassed(true);
3288
4032
  assessment.complete({
3289
4033
  checkId,
3290
4034
  interactionType: INTERACTION8,
@@ -3299,8 +4043,16 @@ function ImageSequencingInner(props, ref) {
3299
4043
  /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("ol", { "data-testid": "image-sequencing-list", children: order.map((id, index) => {
3300
4044
  const image = props.images.find((i) => i.id === id);
3301
4045
  if (!image) return null;
4046
+ const resolvedSrc = resolveMediaSrc(image.src, mediaOptions);
3302
4047
  return /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)("li", { "data-testid": `sequencing-item-${id}`, children: [
3303
- /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("img", { src: image.src, alt: image.alt, style: { maxWidth: "8rem", verticalAlign: "middle" } }),
4048
+ resolvedSrc ? /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
4049
+ "img",
4050
+ {
4051
+ src: resolvedSrc,
4052
+ alt: image.alt,
4053
+ style: { maxWidth: "8rem", verticalAlign: "middle" }
4054
+ }
4055
+ ) : /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("span", { "aria-hidden": "true", children: "!" }),
3304
4056
  /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
3305
4057
  "button",
3306
4058
  {
@@ -3353,6 +4105,7 @@ var import_jsx_runtime25 = require("react/jsx-runtime");
3353
4105
  var INTERACTION9 = "arithmeticQuiz";
3354
4106
  function ArithmeticQuizInner(props, ref) {
3355
4107
  const checkId = (0, import_react35.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
4108
+ const { config } = useLessonkit();
3356
4109
  const assessment = useAssessmentState(props.enclosingLessonId);
3357
4110
  const problemsKey = props.problems.map((p) => `${p.question}\0${p.answer}`).join("|");
3358
4111
  const [answers, setAnswers] = (0, import_react35.useState)(
@@ -3393,9 +4146,9 @@ function ArithmeticQuizInner(props, ref) {
3393
4146
  response: answers,
3394
4147
  correct: passedThreshold
3395
4148
  });
3396
- if (passedThreshold && !completedRef.current) {
4149
+ if ((passedThreshold || props.enableRetry === false) && !completedRef.current) {
3397
4150
  completedRef.current = true;
3398
- setPassed(true);
4151
+ setPassed(passedThreshold);
3399
4152
  assessment.complete({
3400
4153
  checkId,
3401
4154
  interactionType: INTERACTION9,
@@ -3419,7 +4172,7 @@ function ArithmeticQuizInner(props, ref) {
3419
4172
  const handle = (0, import_react35.useMemo)(
3420
4173
  () => buildAssessmentHandle({
3421
4174
  checkId,
3422
- getScore: () => passed ? score : 0,
4175
+ getScore: () => score,
3423
4176
  getMaxScore: () => maxScore,
3424
4177
  getAnswerGiven: () => allFilled,
3425
4178
  resetTask: reset,
@@ -3430,7 +4183,7 @@ function ArithmeticQuizInner(props, ref) {
3430
4183
  interactionType: INTERACTION9,
3431
4184
  response: answers,
3432
4185
  correct: passedThreshold,
3433
- score: passed ? score : 0,
4186
+ score,
3434
4187
  maxScore
3435
4188
  }),
3436
4189
  getCurrentState: () => ({ answers, passed, checked, timeLeft }),
@@ -3444,17 +4197,18 @@ function ArithmeticQuizInner(props, ref) {
3444
4197
  readBooleanStateField(state, "passed", (value) => {
3445
4198
  setPassed(value);
3446
4199
  completedRef.current = value;
3447
- if (value && !telemetryReplayedRef.current) {
4200
+ if (value && !telemetryReplayedRef.current && shouldReplayResumeTelemetry(config)) {
3448
4201
  telemetryReplayedRef.current = true;
3449
4202
  let nextScore = 0;
3450
4203
  props.problems.forEach((p, i) => {
3451
4204
  if ((nextAnswers[i] ?? "").trim() === p.answer.trim()) nextScore += 1;
3452
4205
  });
4206
+ const replayCorrect = nextScore >= (props.passingScore ?? maxScore);
3453
4207
  assessment.answer({
3454
4208
  checkId,
3455
4209
  interactionType: INTERACTION9,
3456
4210
  response: nextAnswers,
3457
- correct: true
4211
+ correct: replayCorrect
3458
4212
  });
3459
4213
  assessment.complete({
3460
4214
  checkId,
@@ -3469,7 +4223,7 @@ function ArithmeticQuizInner(props, ref) {
3469
4223
  if (typeof state.timeLeft === "number") setTimeLeft(state.timeLeft);
3470
4224
  }
3471
4225
  }),
3472
- [allFilled, answers, checkId, checked, maxScore, passed, passedThreshold, score, timeLeft]
4226
+ [allFilled, answers, checkId, checked, config, maxScore, passed, passedThreshold, props.problems, props.passingScore, score, timeLeft]
3473
4227
  );
3474
4228
  useAssessmentHandleRegistration(checkId, handle, ref);
3475
4229
  const onInput = (index, value) => {
@@ -3533,6 +4287,7 @@ var import_jsx_runtime26 = require("react/jsx-runtime");
3533
4287
  var INTERACTION10 = "essay";
3534
4288
  function EssayInner(props, ref) {
3535
4289
  const checkId = (0, import_react36.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
4290
+ const { config } = useLessonkit();
3536
4291
  const assessment = useAssessmentState(props.enclosingLessonId);
3537
4292
  const [text, setText] = (0, import_react36.useState)("");
3538
4293
  const [submitted, setSubmitted] = (0, import_react36.useState)(false);
@@ -3572,16 +4327,22 @@ function EssayInner(props, ref) {
3572
4327
  const nextText = readStringField(state, "text");
3573
4328
  if (typeof nextText === "string") setText(nextText);
3574
4329
  readBooleanStateField(state, "submitted", (value) => {
4330
+ const textVal = typeof nextText === "string" ? nextText : text;
4331
+ const meetsMin = textVal.trim().length >= minLength;
4332
+ if (value && !meetsMin) {
4333
+ setSubmitted(false);
4334
+ completedRef.current = false;
4335
+ return;
4336
+ }
3575
4337
  setSubmitted(value);
3576
4338
  completedRef.current = value;
3577
- if (value && !telemetryReplayedRef.current) {
4339
+ if (value && !telemetryReplayedRef.current && shouldReplayResumeTelemetry(config)) {
3578
4340
  telemetryReplayedRef.current = true;
3579
- const response = typeof nextText === "string" ? nextText : text;
3580
4341
  assessment.answer({
3581
4342
  checkId,
3582
4343
  interactionType: INTERACTION10,
3583
4344
  question: props.question,
3584
- response,
4345
+ response: textVal,
3585
4346
  correct: false
3586
4347
  });
3587
4348
  assessment.complete({
@@ -3595,7 +4356,7 @@ function EssayInner(props, ref) {
3595
4356
  });
3596
4357
  }
3597
4358
  }),
3598
- [checkId, meetsMinLength, props.question, submitted, text]
4359
+ [assessment, checkId, config, meetsMinLength, minLength, props.passingScore, props.question, submitted, text]
3599
4360
  );
3600
4361
  useAssessmentHandleRegistration(checkId, handle, ref);
3601
4362
  const submit = () => {
@@ -3773,16 +4534,29 @@ function MemoryGame(props) {
3773
4534
  const [revealed, setRevealed] = (0, import_react38.useState)(() => /* @__PURE__ */ new Set());
3774
4535
  const [selection, setSelection] = (0, import_react38.useState)(null);
3775
4536
  const [complete, setComplete] = (0, import_react38.useState)(false);
4537
+ const mismatchTimeoutRef = (0, import_react38.useRef)(null);
3776
4538
  const { track } = useLessonkit();
3777
4539
  const lessonId = useEnclosingLessonId();
3778
4540
  const trackOpts = lessonId ? { lessonId } : void 0;
3779
4541
  (0, import_react38.useEffect)(() => {
4542
+ if (mismatchTimeoutRef.current !== null) {
4543
+ window.clearTimeout(mismatchTimeoutRef.current);
4544
+ mismatchTimeoutRef.current = null;
4545
+ }
3780
4546
  setCards(buildDeck2(props.pairs));
3781
4547
  setMatched(/* @__PURE__ */ new Set());
3782
4548
  setRevealed(/* @__PURE__ */ new Set());
3783
4549
  setSelection(null);
3784
4550
  setComplete(false);
3785
4551
  }, [props.blockId, pairsKey]);
4552
+ (0, import_react38.useEffect)(
4553
+ () => () => {
4554
+ if (mismatchTimeoutRef.current !== null) {
4555
+ window.clearTimeout(mismatchTimeoutRef.current);
4556
+ }
4557
+ },
4558
+ []
4559
+ );
3786
4560
  const cardIndexByKey = (0, import_react38.useMemo)(
3787
4561
  () => Object.fromEntries(cards.map((c, i) => [c.cardKey, i])),
3788
4562
  [cards]
@@ -3812,7 +4586,11 @@ function MemoryGame(props) {
3812
4586
  setRevealed(/* @__PURE__ */ new Set());
3813
4587
  setSelection(null);
3814
4588
  } else {
3815
- window.setTimeout(() => {
4589
+ if (mismatchTimeoutRef.current !== null) {
4590
+ window.clearTimeout(mismatchTimeoutRef.current);
4591
+ }
4592
+ mismatchTimeoutRef.current = window.setTimeout(() => {
4593
+ mismatchTimeoutRef.current = null;
3816
4594
  setRevealed((prev) => {
3817
4595
  const next = new Set(prev);
3818
4596
  next.delete(firstKey);
@@ -3969,10 +4747,16 @@ function usePrefersReducedMotion() {
3969
4747
  function ParallaxSlideshow(props) {
3970
4748
  const [index, setIndex] = (0, import_react40.useState)(0);
3971
4749
  const reducedMotion = usePrefersReducedMotion();
3972
- const { track } = useLessonkit();
4750
+ const { track, config } = useLessonkit();
3973
4751
  const lessonId = useEnclosingLessonId();
3974
4752
  const trackOpts = lessonId ? { lessonId } : void 0;
3975
4753
  const slide = props.slides[index];
4754
+ const mediaOptions = { allowedHosts: config.embed?.allowedHosts };
4755
+ const resolvedImageSrc = slide?.imageSrc ? resolveMediaSrc(slide.imageSrc, mediaOptions) : null;
4756
+ (0, import_react40.useEffect)(() => {
4757
+ if (props.slides.length < 1) return;
4758
+ setIndex((current) => Math.min(current, props.slides.length - 1));
4759
+ }, [props.slides.length]);
3976
4760
  (0, import_react40.useEffect)(() => {
3977
4761
  track(
3978
4762
  "parallax_slide_viewed",
@@ -3999,22 +4783,23 @@ function ParallaxSlideshow(props) {
3999
4783
  "data-testid": `parallax-slide-${index}`,
4000
4784
  style: reducedMotion ? void 0 : {
4001
4785
  backgroundAttachment: "fixed",
4002
- backgroundImage: slide.imageSrc ? `url(${slide.imageSrc})` : void 0,
4786
+ backgroundImage: resolvedImageSrc ? `url("${resolvedImageSrc}")` : void 0,
4003
4787
  backgroundPosition: "center",
4004
4788
  backgroundSize: "cover",
4005
4789
  minHeight: "12rem",
4006
4790
  padding: "1rem"
4007
4791
  },
4008
4792
  children: [
4009
- reducedMotion && slide.imageSrc ? /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(
4793
+ reducedMotion && resolvedImageSrc ? /* @__PURE__ */ (0, import_jsx_runtime30.jsx)(
4010
4794
  "img",
4011
4795
  {
4012
- src: slide.imageSrc,
4796
+ src: resolvedImageSrc,
4013
4797
  alt: "",
4014
4798
  "data-testid": "parallax-slide-image",
4015
4799
  style: { maxWidth: "100%" }
4016
4800
  }
4017
4801
  ) : null,
4802
+ !reducedMotion && slide.imageSrc && !resolvedImageSrc ? /* @__PURE__ */ (0, import_jsx_runtime30.jsx)("p", { role: "alert", children: "This image URL is not allowed." }) : null,
4018
4803
  /* @__PURE__ */ (0, import_jsx_runtime30.jsx)("h3", { "data-testid": "parallax-slide-title", children: slide.title }),
4019
4804
  /* @__PURE__ */ (0, import_jsx_runtime30.jsx)("p", { "data-testid": "parallax-slide-body", children: slide.body })
4020
4805
  ]
@@ -4203,8 +4988,9 @@ var import_react44 = require("react");
4203
4988
  var import_jsx_runtime34 = require("react/jsx-runtime");
4204
4989
  function ImageHotspots(props) {
4205
4990
  const [active, setActive] = (0, import_react44.useState)(null);
4206
- const { track } = useLessonkit();
4991
+ const { track, config } = useLessonkit();
4207
4992
  const lessonId = useEnclosingLessonId();
4993
+ const resolvedSrc = resolveMediaSrc(props.src, { allowedHosts: config.embed?.allowedHosts });
4208
4994
  const open = (hotspotId) => {
4209
4995
  setActive(hotspotId);
4210
4996
  track(
@@ -4215,7 +5001,7 @@ function ImageHotspots(props) {
4215
5001
  };
4216
5002
  return /* @__PURE__ */ (0, import_jsx_runtime34.jsxs)("section", { "aria-label": "Image hotspots", "data-lk-block-id": props.blockId, "data-testid": "image-hotspots", children: [
4217
5003
  /* @__PURE__ */ (0, import_jsx_runtime34.jsxs)("div", { style: { position: "relative", display: "inline-block" }, children: [
4218
- /* @__PURE__ */ (0, import_jsx_runtime34.jsx)("img", { src: props.src, alt: props.alt, style: { maxWidth: "100%" } }),
5004
+ resolvedSrc ? /* @__PURE__ */ (0, import_jsx_runtime34.jsx)("img", { src: resolvedSrc, alt: props.alt, style: { maxWidth: "100%" } }) : /* @__PURE__ */ (0, import_jsx_runtime34.jsx)("p", { role: "alert", children: "This image URL is not allowed." }),
4219
5005
  props.hotspots.map((h) => /* @__PURE__ */ (0, import_jsx_runtime34.jsx)(
4220
5006
  "button",
4221
5007
  {
@@ -4248,9 +5034,11 @@ var import_react45 = require("react");
4248
5034
  var import_jsx_runtime35 = require("react/jsx-runtime");
4249
5035
  function ImageSlider(props) {
4250
5036
  const [index, setIndex] = (0, import_react45.useState)(0);
4251
- const { track } = useLessonkit();
5037
+ const { track, config } = useLessonkit();
4252
5038
  const lessonId = useEnclosingLessonId();
4253
5039
  const slide = props.slides[index];
5040
+ const mediaOptions = { allowedHosts: config.embed?.allowedHosts };
5041
+ const resolvedSrc = slide ? resolveMediaSrc(slide.src, mediaOptions) : null;
4254
5042
  if (!slide) return null;
4255
5043
  const goTo = (next) => {
4256
5044
  setIndex(next);
@@ -4261,7 +5049,7 @@ function ImageSlider(props) {
4261
5049
  );
4262
5050
  };
4263
5051
  return /* @__PURE__ */ (0, import_jsx_runtime35.jsxs)("section", { "aria-label": "Image slider", "data-lk-block-id": props.blockId, "data-testid": "image-slider", children: [
4264
- /* @__PURE__ */ (0, import_jsx_runtime35.jsx)("img", { src: slide.src, alt: slide.alt, style: { maxWidth: "100%" } }),
5052
+ resolvedSrc ? /* @__PURE__ */ (0, import_jsx_runtime35.jsx)("img", { src: resolvedSrc, alt: slide.alt, style: { maxWidth: "100%" } }) : /* @__PURE__ */ (0, import_jsx_runtime35.jsx)("p", { role: "alert", children: "This image URL is not allowed." }),
4265
5053
  slide.caption ? /* @__PURE__ */ (0, import_jsx_runtime35.jsx)("p", { children: slide.caption }) : null,
4266
5054
  /* @__PURE__ */ (0, import_jsx_runtime35.jsxs)("nav", { "aria-label": "Slide navigation", children: [
4267
5055
  /* @__PURE__ */ (0, import_jsx_runtime35.jsx)(
@@ -4300,6 +5088,8 @@ var import_jsx_runtime36 = require("react/jsx-runtime");
4300
5088
  var INTERACTION11 = "findHotspot";
4301
5089
  function FindHotspotInner(props, ref) {
4302
5090
  const checkId = (0, import_react46.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
5091
+ const { config } = useLessonkit();
5092
+ const resolvedSrc = resolveMediaSrc(props.src, { allowedHosts: config.embed?.allowedHosts });
4303
5093
  const [selected, setSelected] = (0, import_react46.useState)(null);
4304
5094
  const [checked, setChecked] = (0, import_react46.useState)(false);
4305
5095
  const telemetryReplayedRef = (0, import_react46.useRef)(false);
@@ -4335,7 +5125,7 @@ function FindHotspotInner(props, ref) {
4335
5125
  checkId,
4336
5126
  getScore: () => checked && correct ? 1 : 0,
4337
5127
  getMaxScore: () => 1,
4338
- getAnswerGiven: () => selected !== null,
5128
+ getAnswerGiven: () => checked,
4339
5129
  resetTask: () => {
4340
5130
  setSelected(null);
4341
5131
  setChecked(false);
@@ -4365,10 +5155,12 @@ function FindHotspotInner(props, ref) {
4365
5155
  setChecked(value);
4366
5156
  });
4367
5157
  const nextCorrect = nextSelected === props.correctTargetId;
4368
- replayTelemetry(nextSelected, nextChecked, nextCorrect);
5158
+ if (config.tracking?.replayResumeEvents === true) {
5159
+ replayTelemetry(nextSelected, nextChecked, nextCorrect);
5160
+ }
4369
5161
  }
4370
5162
  }),
4371
- [assessment, checkId, checked, correct, props.correctTargetId, props.passingScore, props.targets, selected]
5163
+ [assessment, checkId, checked, config.tracking?.replayResumeEvents, correct, props.correctTargetId, props.passingScore, props.targets, selected]
4372
5164
  );
4373
5165
  useAssessmentHandleRegistration(checkId, handle, ref);
4374
5166
  const selectTarget = (id) => {
@@ -4392,11 +5184,19 @@ function FindHotspotInner(props, ref) {
4392
5184
  maxScore: 1,
4393
5185
  passingScore: props.passingScore ?? 1
4394
5186
  });
5187
+ } else if (props.enableRetry === false) {
5188
+ assessment.complete({
5189
+ checkId,
5190
+ interactionType: INTERACTION11,
5191
+ score: 0,
5192
+ maxScore: 1,
5193
+ passingScore: props.passingScore ?? 1
5194
+ });
4395
5195
  }
4396
5196
  };
4397
5197
  return /* @__PURE__ */ (0, import_jsx_runtime36.jsxs)("section", { "aria-label": "Find the hotspot", "data-lk-check-id": checkId, "data-testid": "find-hotspot", children: [
4398
5198
  /* @__PURE__ */ (0, import_jsx_runtime36.jsxs)("div", { style: { position: "relative", display: "inline-block" }, children: [
4399
- /* @__PURE__ */ (0, import_jsx_runtime36.jsx)("img", { src: props.src, alt: props.alt, style: { maxWidth: "100%" } }),
5199
+ resolvedSrc ? /* @__PURE__ */ (0, import_jsx_runtime36.jsx)("img", { src: resolvedSrc, alt: props.alt, style: { maxWidth: "100%" } }) : /* @__PURE__ */ (0, import_jsx_runtime36.jsx)("p", { role: "alert", children: "This image URL is not allowed." }),
4400
5200
  props.targets.map((t) => /* @__PURE__ */ (0, import_jsx_runtime36.jsx)(
4401
5201
  "button",
4402
5202
  {
@@ -4432,9 +5232,21 @@ var import_jsx_runtime37 = require("react/jsx-runtime");
4432
5232
  var INTERACTION12 = "findMultipleHotspots";
4433
5233
  function FindMultipleHotspotsInner(props, ref) {
4434
5234
  const checkId = (0, import_react47.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
5235
+ const { config } = useLessonkit();
5236
+ const resolvedSrc = resolveMediaSrc(props.src, { allowedHosts: config.embed?.allowedHosts });
4435
5237
  const [selected, setSelected] = (0, import_react47.useState)(/* @__PURE__ */ new Set());
4436
5238
  const [checked, setChecked] = (0, import_react47.useState)(false);
4437
5239
  const assessment = useAssessmentState(props.enclosingLessonId);
5240
+ const correctSet = (0, import_react47.useMemo)(
5241
+ () => new Set(props.correctTargetIds),
5242
+ [props.correctTargetIds]
5243
+ );
5244
+ const targetIdsKey = props.targets.map((t) => t.id).join("\0");
5245
+ const correctIdsKey = props.correctTargetIds.join("\0");
5246
+ (0, import_react47.useEffect)(() => {
5247
+ setSelected(/* @__PURE__ */ new Set());
5248
+ setChecked(false);
5249
+ }, [checkId, correctIdsKey, targetIdsKey]);
4438
5250
  const toggle = (id) => {
4439
5251
  setSelected((prev) => {
4440
5252
  const next = new Set(prev);
@@ -4444,13 +5256,26 @@ function FindMultipleHotspotsInner(props, ref) {
4444
5256
  });
4445
5257
  setChecked(false);
4446
5258
  };
4447
- const correct = selected.size === props.correctTargetIds.length && props.correctTargetIds.every((id) => selected.has(id));
5259
+ const maxScore = props.correctTargetIds.length || 1;
5260
+ const score = props.correctTargetIds.filter((id) => selected.has(id)).length;
5261
+ const wrongSelected = [...selected].filter((id) => !correctSet.has(id)).length;
5262
+ const passedThreshold = meetsPassingThreshold(score, maxScore, props.passingScore) && wrongSelected === 0;
5263
+ const isFactuallyCorrect = (sel) => {
5264
+ const wrong = [...sel].filter((id) => !correctSet.has(id)).length;
5265
+ const matched = props.correctTargetIds.filter((id) => sel.has(id)).length;
5266
+ return wrong === 0 && sel.size === props.correctTargetIds.length && matched === maxScore;
5267
+ };
5268
+ const factualCorrect = checked ? isFactuallyCorrect(selected) : false;
5269
+ const validTargetIds = (0, import_react47.useMemo)(
5270
+ () => new Set(props.targets.map((t) => t.id)),
5271
+ [props.targets]
5272
+ );
4448
5273
  const handle = (0, import_react47.useMemo)(
4449
5274
  () => buildAssessmentHandle({
4450
5275
  checkId,
4451
- getScore: () => checked && correct ? 1 : 0,
4452
- getMaxScore: () => 1,
4453
- getAnswerGiven: () => selected.size > 0,
5276
+ getScore: () => score,
5277
+ getMaxScore: () => maxScore,
5278
+ getAnswerGiven: () => checked,
4454
5279
  resetTask: () => {
4455
5280
  setSelected(/* @__PURE__ */ new Set());
4456
5281
  setChecked(false);
@@ -4460,42 +5285,68 @@ function FindMultipleHotspotsInner(props, ref) {
4460
5285
  checkId,
4461
5286
  interactionType: INTERACTION12,
4462
5287
  response: [...selected],
4463
- correct: checked ? correct : void 0,
4464
- score: checked && correct ? 1 : 0,
4465
- maxScore: 1
5288
+ correct: checked ? factualCorrect : void 0,
5289
+ score: checked ? score : 0,
5290
+ maxScore
4466
5291
  }),
4467
5292
  getCurrentState: () => ({ selected: [...selected], checked }),
4468
5293
  resume: (state) => {
4469
5294
  const raw = state.selected;
4470
- if (Array.isArray(raw)) setSelected(new Set(raw.filter((id) => typeof id === "string")));
5295
+ if (Array.isArray(raw)) {
5296
+ setSelected(
5297
+ new Set(
5298
+ raw.filter(
5299
+ (id) => typeof id === "string" && validTargetIds.has(id)
5300
+ )
5301
+ )
5302
+ );
5303
+ }
4471
5304
  readBooleanStateField(state, "checked", setChecked);
4472
5305
  }
4473
5306
  }),
4474
- [checkId, selected, checked, correct, props.correctTargetIds]
5307
+ [
5308
+ checkId,
5309
+ checked,
5310
+ factualCorrect,
5311
+ maxScore,
5312
+ props.correctTargetIds,
5313
+ score,
5314
+ selected,
5315
+ validTargetIds
5316
+ ]
4475
5317
  );
4476
5318
  useAssessmentHandleRegistration(checkId, handle, ref);
4477
5319
  const submit = () => {
4478
5320
  if (selected.size === 0 || checked) return;
5321
+ const correctAtSubmit = isFactuallyCorrect(selected);
4479
5322
  setChecked(true);
4480
5323
  assessment.answer({
4481
5324
  checkId,
4482
5325
  interactionType: INTERACTION12,
4483
5326
  response: [...selected],
4484
- correct
5327
+ correct: correctAtSubmit
4485
5328
  });
4486
- if (correct) {
5329
+ if (passedThreshold) {
4487
5330
  assessment.complete({
4488
5331
  checkId,
4489
5332
  interactionType: INTERACTION12,
4490
- score: 1,
4491
- maxScore: 1,
4492
- passingScore: props.passingScore ?? 1
5333
+ score,
5334
+ maxScore,
5335
+ passingScore: props.passingScore ?? maxScore
5336
+ });
5337
+ } else if (props.enableRetry === false) {
5338
+ assessment.complete({
5339
+ checkId,
5340
+ interactionType: INTERACTION12,
5341
+ score,
5342
+ maxScore,
5343
+ passingScore: props.passingScore ?? maxScore
4493
5344
  });
4494
5345
  }
4495
5346
  };
4496
5347
  return /* @__PURE__ */ (0, import_jsx_runtime37.jsxs)("section", { "aria-label": "Find multiple hotspots", "data-lk-check-id": checkId, "data-testid": "find-multiple-hotspots", children: [
4497
5348
  /* @__PURE__ */ (0, import_jsx_runtime37.jsxs)("div", { style: { position: "relative", display: "inline-block" }, children: [
4498
- /* @__PURE__ */ (0, import_jsx_runtime37.jsx)("img", { src: props.src, alt: props.alt, style: { maxWidth: "100%" } }),
5349
+ resolvedSrc ? /* @__PURE__ */ (0, import_jsx_runtime37.jsx)("img", { src: resolvedSrc, alt: props.alt, style: { maxWidth: "100%" } }) : /* @__PURE__ */ (0, import_jsx_runtime37.jsx)("p", { role: "alert", children: "This image URL is not allowed." }),
4499
5350
  props.targets.map((t) => /* @__PURE__ */ (0, import_jsx_runtime37.jsx)(
4500
5351
  "button",
4501
5352
  {
@@ -4516,7 +5367,7 @@ function FindMultipleHotspotsInner(props, ref) {
4516
5367
  ))
4517
5368
  ] }),
4518
5369
  /* @__PURE__ */ (0, import_jsx_runtime37.jsx)("button", { type: "button", "data-testid": "check-hotspots", disabled: selected.size === 0, onClick: submit, children: "Check" }),
4519
- checked ? /* @__PURE__ */ (0, import_jsx_runtime37.jsx)("p", { role: "status", children: correct ? "Correct" : "Try again" }) : null
5370
+ checked ? /* @__PURE__ */ (0, import_jsx_runtime37.jsx)("p", { role: "status", children: passedThreshold ? "Correct" : "Try again" }) : null
4520
5371
  ] });
4521
5372
  }
4522
5373
  var FindMultipleHotspotsInnerForwarded = (0, import_react47.forwardRef)(FindMultipleHotspotsInner);
@@ -4526,14 +5377,685 @@ var FindMultipleHotspots = (0, import_react47.forwardRef)(
4526
5377
  }
4527
5378
  );
4528
5379
  setLessonkitBlockType(FindMultipleHotspots, "FindMultipleHotspots");
5380
+
5381
+ // src/blocks/BranchingScenario.tsx
5382
+ var import_react51 = __toESM(require("react"), 1);
5383
+ var import_core23 = require("@lessonkit/core");
5384
+
5385
+ // src/compound/useCompoundBranchHandle.ts
5386
+ var import_react48 = require("react");
5387
+ var import_core21 = require("@lessonkit/core");
5388
+ function useCompoundBranchHandle(ref, opts) {
5389
+ const bridgeRef = useCompoundHydrationBridgeRef();
5390
+ const {
5391
+ activePageIndex,
5392
+ getRegisteredHandles,
5393
+ visitedNodeIndices,
5394
+ choiceScores,
5395
+ meta,
5396
+ maxChoiceScore = 0,
5397
+ onResetMeta,
5398
+ enableSolutionsButton
5399
+ } = opts;
5400
+ const filterVisited = (0, import_react48.useCallback)(
5401
+ (handles) => {
5402
+ const filtered = [];
5403
+ for (const entry of handles) {
5404
+ const { pageIndex } = entry;
5405
+ if (pageIndex === void 0) continue;
5406
+ if (visitedNodeIndices.has(pageIndex)) filtered.push(entry);
5407
+ }
5408
+ return filtered;
5409
+ },
5410
+ [visitedNodeIndices]
5411
+ );
5412
+ (0, import_react48.useImperativeHandle)(
5413
+ ref,
5414
+ () => ({
5415
+ getScore: () => {
5416
+ const assessment = aggregateAssessmentScores(filterVisited(getRegisteredHandles().values()));
5417
+ return assessment.score + sumChoiceScores(choiceScores);
5418
+ },
5419
+ getMaxScore: () => {
5420
+ const assessment = aggregateAssessmentScores(filterVisited(getRegisteredHandles().values()));
5421
+ return assessment.maxScore + maxChoiceScore;
5422
+ },
5423
+ getAnswerGiven: () => aggregateAssessmentScores(filterVisited(getRegisteredHandles().values())).allAnswered,
5424
+ resetTask: () => {
5425
+ onResetMeta();
5426
+ for (const entry of filterVisited(getRegisteredHandles().values())) {
5427
+ entry.handle.resetTask();
5428
+ }
5429
+ },
5430
+ showSolutions: () => {
5431
+ if (!enableSolutionsButton) return;
5432
+ for (const entry of filterVisited(getRegisteredHandles().values())) {
5433
+ entry.handle.showSolutions();
5434
+ }
5435
+ },
5436
+ getCurrentState: () => {
5437
+ const childStates = {};
5438
+ for (const [checkId, entry] of getRegisteredHandles()) {
5439
+ const { pageIndex } = entry;
5440
+ if (pageIndex === void 0 || !visitedNodeIndices.has(pageIndex)) continue;
5441
+ if (entry.handle.getCurrentState) {
5442
+ childStates[checkId] = entry.handle.getCurrentState();
5443
+ }
5444
+ }
5445
+ return mergeBranchMetaIntoState(
5446
+ (0, import_core21.createCompoundResumeState)({ activePageIndex, childStates }),
5447
+ meta
5448
+ );
5449
+ },
5450
+ resume: (state) => {
5451
+ bridgeRef?.current?.notifyImperativeResume(state);
5452
+ }
5453
+ }),
5454
+ [
5455
+ activePageIndex,
5456
+ bridgeRef,
5457
+ choiceScores,
5458
+ enableSolutionsButton,
5459
+ filterVisited,
5460
+ getRegisteredHandles,
5461
+ meta,
5462
+ maxChoiceScore,
5463
+ onResetMeta,
5464
+ visitedNodeIndices
5465
+ ]
5466
+ );
5467
+ }
5468
+
5469
+ // src/compound/useBranchingScenario.tsx
5470
+ var import_react49 = require("react");
5471
+ var import_jsx_runtime38 = require("react/jsx-runtime");
5472
+ var BranchingScenarioContext = (0, import_react49.createContext)(null);
5473
+ function BranchingScenarioProvider(props) {
5474
+ return /* @__PURE__ */ (0, import_jsx_runtime38.jsx)(BranchingScenarioContext.Provider, { value: props.value, children: props.children });
5475
+ }
5476
+ function useBranchingScenarioOptional() {
5477
+ return (0, import_react49.useContext)(BranchingScenarioContext);
5478
+ }
5479
+
5480
+ // src/compound/validateBranchGraph.ts
5481
+ var import_react50 = __toESM(require("react"), 1);
5482
+ var import_core22 = require("@lessonkit/core");
5483
+ function extractBranchGraph(nodes) {
5484
+ return nodes.map((node) => {
5485
+ const choices = [];
5486
+ import_react50.default.Children.forEach(node.props.children, (child) => {
5487
+ if (!import_react50.default.isValidElement(child)) return;
5488
+ if (getLessonkitBlockType(child.type) !== "BranchChoice") return;
5489
+ const targetNodeId = child.props.targetNodeId;
5490
+ if (typeof targetNodeId === "string") {
5491
+ choices.push({ targetNodeId: normalizeComponentId(targetNodeId, "blockId") });
5492
+ }
5493
+ });
5494
+ return {
5495
+ nodeId: normalizeComponentId(node.props.nodeId, "blockId"),
5496
+ terminal: Boolean(node.props.terminal),
5497
+ choices
5498
+ };
5499
+ });
5500
+ }
5501
+ function validateBranchGraphAtMount(startNodeId, nodes, strict) {
5502
+ const graph = extractBranchGraph(nodes);
5503
+ const result = (0, import_core22.validateBranchGraph)(startNodeId, graph.map(({ nodeId, choices }) => ({ nodeId, choices })));
5504
+ for (const node of graph) {
5505
+ if (node.terminal && node.choices.length > 0) {
5506
+ const msg = `[lessonkit] BranchingScenario: terminal node "${node.nodeId}" must not contain BranchChoice children`;
5507
+ if (strict || !isDevEnvironment()) {
5508
+ throw new Error(msg);
5509
+ }
5510
+ console.warn(msg);
5511
+ }
5512
+ }
5513
+ for (const issue of result.issues) {
5514
+ const msg = `[lessonkit] BranchingScenario: ${issue.message}`;
5515
+ if (strict || !isDevEnvironment()) {
5516
+ throw new Error(msg);
5517
+ }
5518
+ console.warn(msg);
5519
+ }
5520
+ }
5521
+ function buildNodeIndexMap(nodes) {
5522
+ const map = /* @__PURE__ */ new Map();
5523
+ nodes.forEach((node, index) => {
5524
+ map.set(normalizeComponentId(node.props.nodeId, "blockId"), index);
5525
+ });
5526
+ return map;
5527
+ }
5528
+ function buildNodeLabels(nodes) {
5529
+ const map = /* @__PURE__ */ new Map();
5530
+ for (const node of nodes) {
5531
+ const nodeId = normalizeComponentId(node.props.nodeId, "blockId");
5532
+ map.set(nodeId, node.props.title ?? nodeId);
5533
+ }
5534
+ return map;
5535
+ }
5536
+ function filterBranchNodeContent(children) {
5537
+ const filtered = [];
5538
+ import_react50.default.Children.forEach(children, (child) => {
5539
+ if (!import_react50.default.isValidElement(child)) {
5540
+ filtered.push(child);
5541
+ return;
5542
+ }
5543
+ if (getLessonkitBlockType(child.type) === "BranchChoice") return;
5544
+ filtered.push(child);
5545
+ });
5546
+ return filtered;
5547
+ }
5548
+ function nodeHasChoices(node) {
5549
+ let hasChoice = false;
5550
+ import_react50.default.Children.forEach(node.props.children, (child) => {
5551
+ if (!import_react50.default.isValidElement(child)) return;
5552
+ if (getLessonkitBlockType(child.type) === "BranchChoice") hasChoice = true;
5553
+ });
5554
+ return hasChoice;
5555
+ }
5556
+
5557
+ // src/blocks/BranchingScenario.tsx
5558
+ var import_jsx_runtime39 = require("react/jsx-runtime");
5559
+ var BranchingScenarioInner = (0, import_react51.forwardRef)(function BranchingScenarioInner2(props, ref) {
5560
+ const { blockId, nodes, persistEnabled, startNodeId } = props;
5561
+ validateCompoundChildren("BranchingScenario", nodes);
5562
+ (0, import_react51.useLayoutEffect)(() => {
5563
+ validateBranchGraphAtMount(startNodeId, nodes);
5564
+ }, [startNodeId, nodes]);
5565
+ const { config, track, storage } = useLessonkit();
5566
+ const lessonId = useEnclosingLessonId();
5567
+ const ctx = useCompoundRegistry();
5568
+ const nodeIndexMap = (0, import_react51.useMemo)(() => buildNodeIndexMap(nodes), [nodes]);
5569
+ const nodeLabels = (0, import_react51.useMemo)(() => buildNodeLabels(nodes), [nodes]);
5570
+ const [meta, setMeta] = (0, import_react51.useState)(() => createInitialBranchMeta(startNodeId));
5571
+ const metaRef = (0, import_react51.useRef)(meta);
5572
+ const branchViewedRef = (0, import_react51.useRef)(/* @__PURE__ */ new Set());
5573
+ const legacyResumeWarnedRef = (0, import_react51.useRef)(false);
5574
+ const commitMeta = (0, import_react51.useCallback)((next) => {
5575
+ metaRef.current = next;
5576
+ setMeta(next);
5577
+ }, []);
5578
+ const activeIndex = nodeIndexMap.get(meta.activeNodeId) ?? 0;
5579
+ const visitedNodeIndices = (0, import_react51.useMemo)(() => {
5580
+ const indices = /* @__PURE__ */ new Set();
5581
+ for (const nodeId of meta.visitedNodeIds) {
5582
+ const i = nodeIndexMap.get(nodeId);
5583
+ if (i !== void 0) indices.add(i);
5584
+ }
5585
+ return indices;
5586
+ }, [meta.visitedNodeIds, nodeIndexMap]);
5587
+ const syncBranchViewedRef = (0, import_react51.useCallback)(
5588
+ (restoredMeta) => {
5589
+ const next = /* @__PURE__ */ new Set();
5590
+ for (const nodeId of restoredMeta.visitedNodeIds) {
5591
+ if (nodeId !== restoredMeta.activeNodeId) {
5592
+ next.add(`${blockId}:${nodeId}`);
5593
+ }
5594
+ }
5595
+ branchViewedRef.current = next;
5596
+ },
5597
+ [blockId]
5598
+ );
5599
+ const applyResumeState = (0, import_react51.useCallback)(
5600
+ (state) => {
5601
+ const fromMeta = readBranchingScenarioMeta(state.childStates);
5602
+ if (fromMeta) {
5603
+ const sanitized = sanitizeBranchMeta(fromMeta, nodeIndexMap, startNodeId);
5604
+ commitMeta(sanitized);
5605
+ syncBranchViewedRef(sanitized);
5606
+ return;
5607
+ }
5608
+ const hasChildCheckStates = Object.keys(state.childStates).some((k) => k !== BS_META_KEY);
5609
+ const clampedIndex = (0, import_core23.clampCompoundPageIndex)(state.activePageIndex, nodes.length);
5610
+ const nodeAtIndex = nodes[clampedIndex];
5611
+ if (nodeAtIndex || hasChildCheckStates) {
5612
+ const nodeId = nodeAtIndex?.props.nodeId ?? startNodeId;
5613
+ const visitedNodeIds = [startNodeId];
5614
+ if (nodeId !== startNodeId) visitedNodeIds.push(nodeId);
5615
+ const legacyMeta = sanitizeBranchMeta(
5616
+ { activeNodeId: nodeId, visitedNodeIds, choiceScores: {} },
5617
+ nodeIndexMap,
5618
+ startNodeId
5619
+ );
5620
+ commitMeta(legacyMeta);
5621
+ syncBranchViewedRef(legacyMeta);
5622
+ if (!legacyResumeWarnedRef.current && isDevEnvironment() && (hasChildCheckStates || state.activePageIndex !== 0)) {
5623
+ legacyResumeWarnedRef.current = true;
5624
+ console.warn(
5625
+ "[lessonkit] BranchingScenario: legacy save without branch meta; restored via activePageIndex and child states"
5626
+ );
5627
+ }
5628
+ return;
5629
+ }
5630
+ if (!legacyResumeWarnedRef.current && isDevEnvironment() && (state.activePageIndex !== 0 || Object.keys(state.childStates).length > 0)) {
5631
+ legacyResumeWarnedRef.current = true;
5632
+ console.warn(
5633
+ "[lessonkit] BranchingScenario: legacy save without branch meta; starting at startNodeId"
5634
+ );
5635
+ }
5636
+ const fresh = sanitizeBranchMeta(createInitialBranchMeta(startNodeId), nodeIndexMap, startNodeId);
5637
+ commitMeta(fresh);
5638
+ syncBranchViewedRef(fresh);
5639
+ },
5640
+ [commitMeta, nodeIndexMap, nodes, startNodeId, syncBranchViewedRef]
5641
+ );
5642
+ const resetBranchMeta = (0, import_react51.useCallback)(() => {
5643
+ commitMeta(createInitialBranchMeta(startNodeId));
5644
+ branchViewedRef.current = /* @__PURE__ */ new Set();
5645
+ }, [commitMeta, startNodeId]);
5646
+ const transformState = (0, import_react51.useCallback)(
5647
+ (state) => mergeBranchMetaIntoState(state, metaRef.current),
5648
+ []
5649
+ );
5650
+ const shouldIncludeChildState = (0, import_react51.useCallback)(
5651
+ (_checkId, pageIndex) => pageIndex !== void 0 && visitedNodeIndices.has(pageIndex),
5652
+ [visitedNodeIndices]
5653
+ );
5654
+ useCompoundPersistence({
5655
+ courseId: config.courseId,
5656
+ compoundId: blockId,
5657
+ pageCount: nodes.length,
5658
+ index: activeIndex,
5659
+ setIndex: () => {
5660
+ },
5661
+ enabled: persistEnabled,
5662
+ storage,
5663
+ transformState,
5664
+ onCompoundResume: applyResumeState,
5665
+ shouldIncludeChildState
5666
+ });
5667
+ const maxChoiceScoreOnPath = (0, import_react51.useMemo)(() => {
5668
+ if (!meta.choiceScores) return 0;
5669
+ const branchFromIds = /* @__PURE__ */ new Set();
5670
+ for (const key of Object.keys(meta.choiceScores)) {
5671
+ const fromId = key.split(":")[0];
5672
+ if (fromId) branchFromIds.add(fromId);
5673
+ }
5674
+ let total = 0;
5675
+ for (const node of nodes) {
5676
+ const nodeId = node.props.nodeId;
5677
+ if (!branchFromIds.has(nodeId)) continue;
5678
+ let maxWeight = 0;
5679
+ let found = false;
5680
+ for (const child of import_react51.default.Children.toArray(node.props.children)) {
5681
+ if (!import_react51.default.isValidElement(child)) continue;
5682
+ const weight = child.props.scoreWeight;
5683
+ if (typeof weight === "number" && Number.isFinite(weight)) {
5684
+ found = true;
5685
+ maxWeight = Math.max(maxWeight, weight);
5686
+ }
5687
+ }
5688
+ if (found) total += maxWeight;
5689
+ }
5690
+ return total;
5691
+ }, [meta.choiceScores, nodes]);
5692
+ useCompoundBranchHandle(ref, {
5693
+ activePageIndex: activeIndex,
5694
+ getRegisteredHandles: () => ctx?.getRegisteredHandles() ?? /* @__PURE__ */ new Map(),
5695
+ visitedNodeIndices,
5696
+ choiceScores: meta.choiceScores ?? {},
5697
+ meta,
5698
+ maxChoiceScore: maxChoiceScoreOnPath,
5699
+ onResetMeta: resetBranchMeta,
5700
+ enableSolutionsButton: props.enableSolutionsButton
5701
+ });
5702
+ const activeNode = nodes[activeIndex];
5703
+ const isTerminal = Boolean(activeNode?.props.terminal) || (activeNode ? !nodeHasChoices(activeNode) && meta.activeNodeId !== startNodeId : false);
5704
+ const visitedLabels = (0, import_react51.useMemo)(
5705
+ () => meta.visitedNodeIds.map((id) => ({
5706
+ nodeId: id,
5707
+ label: nodeLabels.get(id) ?? id
5708
+ })),
5709
+ [meta.visitedNodeIds, nodeLabels]
5710
+ );
5711
+ (0, import_react51.useEffect)(() => {
5712
+ if (!lessonId || !activeNode) return;
5713
+ const dedupeKey = `${blockId}:${meta.activeNodeId}`;
5714
+ if (branchViewedRef.current.has(dedupeKey)) return;
5715
+ branchViewedRef.current.add(dedupeKey);
5716
+ track(
5717
+ "branch_node_viewed",
5718
+ {
5719
+ blockId,
5720
+ nodeId: meta.activeNodeId,
5721
+ nodeIndex: activeIndex,
5722
+ nodeTitle: activeNode.props.title
5723
+ },
5724
+ { lessonId }
5725
+ );
5726
+ }, [activeIndex, activeNode, blockId, lessonId, meta.activeNodeId, track]);
5727
+ const navigateToNode = (0, import_react51.useCallback)(
5728
+ (opts) => {
5729
+ const toNodeId = normalizeComponentId(opts.toNodeId, "blockId");
5730
+ const fromNodeId = normalizeComponentId(opts.fromNodeId, "blockId");
5731
+ if (!nodeIndexMap.has(toNodeId)) {
5732
+ if (isDevEnvironment()) {
5733
+ console.warn(
5734
+ `[lessonkit] BranchingScenario: unknown targetNodeId "${toNodeId}" from "${fromNodeId}"`
5735
+ );
5736
+ }
5737
+ return;
5738
+ }
5739
+ const activeNodeId = metaRef.current.activeNodeId;
5740
+ if (fromNodeId !== activeNodeId) {
5741
+ if (isDevEnvironment()) {
5742
+ console.warn(
5743
+ `[lessonkit] BranchingScenario: navigateToNode from "${fromNodeId}" but active node is "${activeNodeId}"`
5744
+ );
5745
+ }
5746
+ return;
5747
+ }
5748
+ if (lessonId) {
5749
+ track(
5750
+ "branch_selected",
5751
+ {
5752
+ blockId,
5753
+ fromNodeId,
5754
+ toNodeId,
5755
+ label: opts.label,
5756
+ scoreWeight: opts.scoreWeight
5757
+ },
5758
+ { lessonId }
5759
+ );
5760
+ }
5761
+ setMeta((prev) => {
5762
+ const choiceScores = applyChoiceScoreUpdate(
5763
+ prev.choiceScores,
5764
+ fromNodeId,
5765
+ toNodeId,
5766
+ opts.scoreWeight
5767
+ );
5768
+ const visited = prev.visitedNodeIds.includes(toNodeId) ? prev.visitedNodeIds : [...prev.visitedNodeIds, toNodeId];
5769
+ const next = sanitizeBranchMeta(
5770
+ {
5771
+ activeNodeId: toNodeId,
5772
+ visitedNodeIds: visited,
5773
+ choiceScores
5774
+ },
5775
+ nodeIndexMap,
5776
+ startNodeId
5777
+ );
5778
+ metaRef.current = next;
5779
+ return next;
5780
+ });
5781
+ },
5782
+ [blockId, lessonId, nodeIndexMap, startNodeId, track]
5783
+ );
5784
+ const choicesLocked = isTerminal;
5785
+ const contextValue = (0, import_react51.useMemo)(
5786
+ () => ({
5787
+ compoundBlockId: blockId,
5788
+ activeNodeId: meta.activeNodeId,
5789
+ visitedNodeIds: meta.visitedNodeIds,
5790
+ visitedLabels: visitedLabels.map((entry) => entry.label),
5791
+ navigateToNode,
5792
+ isTerminal,
5793
+ choicesLocked
5794
+ }),
5795
+ [blockId, choicesLocked, isTerminal, meta.activeNodeId, meta.visitedNodeIds, navigateToNode, visitedLabels]
5796
+ );
5797
+ const pathScore = ctx ? Array.from(ctx.getRegisteredHandles().values()).filter((h) => h.pageIndex !== void 0 && visitedNodeIndices.has(h.pageIndex)).reduce((s, h) => s + h.handle.getScore(), 0) + sumChoiceScores(meta.choiceScores) : 0;
5798
+ const pathMaxScore = ctx ? Array.from(ctx.getRegisteredHandles().values()).filter((h) => h.pageIndex !== void 0 && visitedNodeIndices.has(h.pageIndex)).reduce((s, h) => s + h.handle.getMaxScore(), 0) + maxChoiceScoreOnPath : 0;
5799
+ return /* @__PURE__ */ (0, import_jsx_runtime39.jsx)(BranchingScenarioProvider, { value: contextValue, children: /* @__PURE__ */ (0, import_jsx_runtime39.jsxs)("section", { "aria-label": props.title, "data-testid": "branching-scenario", "data-lk-block-id": blockId, children: [
5800
+ /* @__PURE__ */ (0, import_jsx_runtime39.jsx)("h3", { children: props.title }),
5801
+ props.showPathScore && ctx ? /* @__PURE__ */ (0, import_jsx_runtime39.jsxs)("p", { "data-testid": "branch-score", children: [
5802
+ "Score: ",
5803
+ pathScore,
5804
+ " / ",
5805
+ pathMaxScore
5806
+ ] }) : null,
5807
+ props.showPathRecap && isTerminal && meta.visitedNodeIds.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime39.jsxs)("aside", { "data-testid": "branch-path-recap", "aria-label": "Your path", children: [
5808
+ /* @__PURE__ */ (0, import_jsx_runtime39.jsx)("h4", { children: "Your path" }),
5809
+ /* @__PURE__ */ (0, import_jsx_runtime39.jsx)("ol", { children: visitedLabels.map((entry) => /* @__PURE__ */ (0, import_jsx_runtime39.jsx)("li", { children: entry.label }, entry.nodeId)) })
5810
+ ] }) : null,
5811
+ /* @__PURE__ */ (0, import_jsx_runtime39.jsx)("div", { "data-testid": "branching-scenario-active-node", children: nodes.map((node, i) => {
5812
+ const content = import_react51.default.Children.map(node.props.children, (child) => {
5813
+ if (!import_react51.default.isValidElement(child)) return child;
5814
+ if (getLessonkitBlockType(child.type) !== "BranchChoice") return child;
5815
+ return import_react51.default.cloneElement(child, {
5816
+ fromNodeId: node.props.nodeId
5817
+ });
5818
+ });
5819
+ return import_react51.default.cloneElement(node, {
5820
+ key: node.key ?? node.props.nodeId,
5821
+ hidden: i !== activeIndex,
5822
+ nodeIndex: i,
5823
+ children: content
5824
+ });
5825
+ }) })
5826
+ ] }) });
5827
+ });
5828
+ var BranchingScenario = (0, import_react51.forwardRef)(
5829
+ function BranchingScenario2(props, ref) {
5830
+ const nodes = import_react51.default.Children.toArray(props.children).filter(
5831
+ import_react51.default.isValidElement
5832
+ );
5833
+ const { config } = useLessonkit();
5834
+ const persistEnabled = config.session?.persistCompoundState !== false;
5835
+ requireCompoundBlockIdWhenPersisting({
5836
+ persistEnabled,
5837
+ blockId: props.blockId,
5838
+ componentName: "BranchingScenario"
5839
+ });
5840
+ const blockId = (0, import_react51.useMemo)(
5841
+ () => normalizeComponentId(props.blockId, "blockId"),
5842
+ [props.blockId]
5843
+ );
5844
+ const startNodeId = (0, import_react51.useMemo)(
5845
+ () => normalizeComponentId(props.startNodeId, "blockId"),
5846
+ [props.startNodeId]
5847
+ );
5848
+ const nodeIndexMap = (0, import_react51.useMemo)(() => buildNodeIndexMap(nodes), [nodes]);
5849
+ const initialIndex = nodeIndexMap.get(startNodeId) ?? 0;
5850
+ const hydrationKey = `${config.courseId ?? "no-course"}:${blockId}`;
5851
+ const setIndexStable = (0, import_react51.useCallback)(() => {
5852
+ }, []);
5853
+ return /* @__PURE__ */ (0, import_jsx_runtime39.jsx)(CompoundProvider, { activePageIndex: initialIndex, onActivePageIndexChange: setIndexStable, children: /* @__PURE__ */ (0, import_jsx_runtime39.jsx)(
5854
+ BranchingScenarioInner,
5855
+ {
5856
+ ...props,
5857
+ startNodeId,
5858
+ ref,
5859
+ blockId,
5860
+ nodes,
5861
+ persistEnabled
5862
+ },
5863
+ hydrationKey
5864
+ ) });
5865
+ }
5866
+ );
5867
+ setLessonkitBlockType(BranchingScenario, "BranchingScenario");
5868
+
5869
+ // src/blocks/BranchNode.tsx
5870
+ var import_react52 = require("react");
5871
+ var import_jsx_runtime40 = require("react/jsx-runtime");
5872
+ function BranchNode(props) {
5873
+ validateCompoundChildren("BranchNode", filterBranchNodeContent(props.children));
5874
+ return /* @__PURE__ */ (0, import_jsx_runtime40.jsxs)(
5875
+ "section",
5876
+ {
5877
+ "aria-label": props.title ?? props.nodeId,
5878
+ "data-lk-node-id": props.nodeId,
5879
+ "data-testid": `branch-node-${props.nodeId}`,
5880
+ hidden: props.hidden ? true : void 0,
5881
+ style: props.hidden ? { display: "none" } : void 0,
5882
+ children: [
5883
+ props.title ? /* @__PURE__ */ (0, import_jsx_runtime40.jsx)("h4", { children: props.title }) : null,
5884
+ /* @__PURE__ */ (0, import_jsx_runtime40.jsx)(CompoundPageIndexProvider, { pageIndex: props.nodeIndex ?? 0, children: /* @__PURE__ */ (0, import_jsx_runtime40.jsx)("div", { children: props.children }) })
5885
+ ]
5886
+ }
5887
+ );
5888
+ }
5889
+ setLessonkitBlockType(BranchNode, "BranchNode");
5890
+
5891
+ // src/blocks/BranchChoice.tsx
5892
+ var import_react53 = require("react");
5893
+ var import_jsx_runtime41 = require("react/jsx-runtime");
5894
+ function BranchChoice(props) {
5895
+ const ctx = useBranchingScenarioOptional();
5896
+ const groupId = (0, import_react53.useId)();
5897
+ const fromNodeId = props.fromNodeId ?? ctx?.activeNodeId ?? "";
5898
+ const isActiveNode = ctx ? ctx.activeNodeId === fromNodeId : true;
5899
+ const locked = ctx?.choicesLocked ?? false;
5900
+ const onSelect = () => {
5901
+ if (!ctx || !fromNodeId || props.disabled || locked || !isActiveNode) return;
5902
+ ctx.navigateToNode({
5903
+ fromNodeId,
5904
+ toNodeId: props.targetNodeId,
5905
+ label: props.label,
5906
+ scoreWeight: props.scoreWeight
5907
+ });
5908
+ };
5909
+ return /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(
5910
+ "button",
5911
+ {
5912
+ type: "button",
5913
+ role: "radio",
5914
+ "aria-checked": false,
5915
+ "aria-labelledby": groupId,
5916
+ "data-testid": `branch-choice-${props.targetNodeId}`,
5917
+ disabled: props.disabled || locked || !isActiveNode,
5918
+ onClick: onSelect,
5919
+ children: /* @__PURE__ */ (0, import_jsx_runtime41.jsx)("span", { id: groupId, children: props.label })
5920
+ }
5921
+ );
5922
+ }
5923
+ setLessonkitBlockType(BranchChoice, "BranchChoice");
5924
+
5925
+ // src/blocks/Embed.tsx
5926
+ var import_react54 = require("react");
5927
+ var import_jsx_runtime42 = require("react/jsx-runtime");
5928
+ function Embed(props) {
5929
+ const blockId = normalizeComponentId(props.blockId, "blockId");
5930
+ const { config, track } = useLessonkit();
5931
+ const lessonId = useEnclosingLessonId();
5932
+ const resolvedSrc = resolveEmbedSrc(props.src, {
5933
+ allowedHosts: config.embed?.allowedHosts
5934
+ });
5935
+ const sandbox = buildEmbedSandbox(props.allow, {
5936
+ restrictPopupsInProduction: config.embed?.restrictPopupsInProduction ?? true
5937
+ });
5938
+ const aspectRatio = resolveEmbedAspectRatio(props.aspectRatio);
5939
+ (0, import_react54.useEffect)(() => {
5940
+ if (!resolvedSrc) return;
5941
+ track(
5942
+ "interaction",
5943
+ { kind: "embed_viewed", blockId, src: telemetryEmbedSrc(resolvedSrc) },
5944
+ lessonId ? { lessonId } : void 0
5945
+ );
5946
+ }, [blockId, lessonId, resolvedSrc, track]);
5947
+ if (!resolvedSrc) {
5948
+ return /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("figure", { "data-lk-block-id": blockId, "data-testid": `embed-${blockId}`, children: /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("p", { role: "alert", children: "This embed URL is not allowed." }) });
5949
+ }
5950
+ return /* @__PURE__ */ (0, import_jsx_runtime42.jsxs)("figure", { "data-lk-block-id": blockId, "data-testid": `embed-${blockId}`, children: [
5951
+ /* @__PURE__ */ (0, import_jsx_runtime42.jsx)(
5952
+ "iframe",
5953
+ {
5954
+ title: props.title,
5955
+ src: resolvedSrc,
5956
+ sandbox,
5957
+ referrerPolicy: "no-referrer",
5958
+ style: aspectRatio ? { aspectRatio, width: "100%", border: 0 } : { width: "100%", border: 0 }
5959
+ }
5960
+ ),
5961
+ /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("figcaption", { className: "lk-visually-hidden", children: props.title })
5962
+ ] });
5963
+ }
5964
+ setLessonkitBlockType(Embed, "Embed");
5965
+
5966
+ // src/blocks/Chart.tsx
5967
+ var import_react55 = require("react");
5968
+
5969
+ // src/blocks/chartUtils.ts
5970
+ function normalizeChartType(type) {
5971
+ if (type === "bar" || type === "pie") return type;
5972
+ if (type !== void 0 && isDevEnvironment()) {
5973
+ console.warn(`[lessonkit] Chart: unknown type "${type}"; rendering data table only.`);
5974
+ }
5975
+ return type === void 0 ? "pie" : "table";
5976
+ }
5977
+ function normalizeChartData(data) {
5978
+ if (!Array.isArray(data)) return [];
5979
+ const rows = [];
5980
+ for (let i = 0; i < data.length; i += 1) {
5981
+ const row = data[i];
5982
+ if (!row || typeof row !== "object") continue;
5983
+ const label = typeof row.label === "string" ? row.label : `Item ${i + 1}`;
5984
+ const raw = Number(row.value);
5985
+ const value = Number.isFinite(raw) && raw >= 0 ? raw : 0;
5986
+ rows.push({ label, value, key: `${label}-${i}` });
5987
+ }
5988
+ return rows;
5989
+ }
5990
+ function chartMaxValue(rows) {
5991
+ if (rows.length === 0) return 1;
5992
+ return Math.max(...rows.map((row) => row.value), 1);
5993
+ }
5994
+
5995
+ // src/blocks/Chart.tsx
5996
+ var import_jsx_runtime43 = require("react/jsx-runtime");
5997
+ function Chart(props) {
5998
+ const blockId = normalizeComponentId(props.blockId, "blockId");
5999
+ const { track } = useLessonkit();
6000
+ const lessonId = useEnclosingLessonId();
6001
+ const chartType = normalizeChartType(props.type);
6002
+ const rows = (0, import_react55.useMemo)(() => normalizeChartData(props.data), [props.data]);
6003
+ const max = (0, import_react55.useMemo)(() => chartMaxValue(rows), [rows]);
6004
+ (0, import_react55.useEffect)(() => {
6005
+ track(
6006
+ "interaction",
6007
+ { kind: "chart_viewed", blockId, chartType },
6008
+ lessonId ? { lessonId } : void 0
6009
+ );
6010
+ }, [blockId, chartType, lessonId, track]);
6011
+ return /* @__PURE__ */ (0, import_jsx_runtime43.jsxs)("figure", { "data-lk-block-id": blockId, "data-testid": `chart-${blockId}`, children: [
6012
+ props.title ? /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("figcaption", { children: props.title }) : null,
6013
+ rows.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("p", { "data-testid": "chart-empty", children: "No chart data." }) : chartType === "table" ? null : chartType === "bar" ? /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("div", { role: "img", "aria-label": props.title ?? "Bar chart", "aria-describedby": `${blockId}-table`, children: rows.map((datum) => /* @__PURE__ */ (0, import_jsx_runtime43.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: "0.5rem" }, children: [
6014
+ /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("span", { style: { minWidth: "6rem" }, children: datum.label }),
6015
+ /* @__PURE__ */ (0, import_jsx_runtime43.jsx)(
6016
+ "div",
6017
+ {
6018
+ style: {
6019
+ height: "1rem",
6020
+ width: `${datum.value / max * 100}%`,
6021
+ background: "var(--lk-color-primary, #2563eb)"
6022
+ },
6023
+ "aria-hidden": true
6024
+ }
6025
+ ),
6026
+ /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("span", { children: datum.value })
6027
+ ] }, datum.key)) }) : /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("ul", { role: "list", "aria-label": props.title ?? "Pie chart segments", children: rows.map((datum) => /* @__PURE__ */ (0, import_jsx_runtime43.jsxs)("li", { children: [
6028
+ datum.label,
6029
+ ": ",
6030
+ datum.value
6031
+ ] }, datum.key)) }),
6032
+ /* @__PURE__ */ (0, import_jsx_runtime43.jsxs)("table", { id: `${blockId}-table`, children: [
6033
+ /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("caption", { children: props.title ?? "Chart data" }),
6034
+ /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("thead", { children: /* @__PURE__ */ (0, import_jsx_runtime43.jsxs)("tr", { children: [
6035
+ /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("th", { scope: "col", children: "Label" }),
6036
+ /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("th", { scope: "col", children: "Value" })
6037
+ ] }) }),
6038
+ /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("tbody", { children: rows.map((datum) => /* @__PURE__ */ (0, import_jsx_runtime43.jsxs)("tr", { children: [
6039
+ /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("th", { scope: "row", children: datum.label }),
6040
+ /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("td", { children: datum.value })
6041
+ ] }, datum.key)) })
6042
+ ] })
6043
+ ] });
6044
+ }
6045
+ setLessonkitBlockType(Chart, "Chart");
4529
6046
  // Annotate the CommonJS export names for ESM import in node:
4530
6047
  0 && (module.exports = {
4531
6048
  Accordion,
4532
6049
  ArithmeticQuiz,
4533
6050
  AssessmentSequence,
6051
+ BranchChoice,
6052
+ BranchNode,
6053
+ BranchingScenario,
6054
+ Chart,
4534
6055
  DialogCards,
4535
6056
  DragAndDrop,
4536
6057
  DragTheWords,
6058
+ Embed,
4537
6059
  Essay,
4538
6060
  FillInTheBlanks,
4539
6061
  FindHotspot,