@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.
@@ -3,7 +3,9 @@ import {
3
3
  CompoundPageIndexProvider,
4
4
  CompoundProvider,
5
5
  LessonkitContext,
6
+ aggregateAssessmentScores,
6
7
  buildAssessmentHandle,
8
+ getLessonkitBlockType,
7
9
  isDevEnvironment,
8
10
  meetsPassingThreshold,
9
11
  normalizeComponentId,
@@ -23,7 +25,7 @@ import {
23
25
  usePluginScoring,
24
26
  validateAccordionSections,
25
27
  validateCompoundChildren
26
- } from "./chunk-TDM3ARE7.js";
28
+ } from "./chunk-7TJQJFYR.js";
27
29
 
28
30
  // src/blocks/TrueFalse.tsx
29
31
  import React, { forwardRef, useEffect, useMemo, useRef, useState } from "react";
@@ -62,7 +64,7 @@ function TrueFalseInner(props, ref) {
62
64
  if (passed) {
63
65
  return { score: completedScore ?? maxScore, maxScore };
64
66
  }
65
- if (selectionCorrect) {
67
+ if (selected !== null && selectionCorrect) {
66
68
  return { score: completedMaxScore ?? maxScore, maxScore };
67
69
  }
68
70
  return { score: 0, maxScore };
@@ -134,7 +136,9 @@ function TrueFalseInner(props, ref) {
134
136
  if (nextPassed) {
135
137
  const maxScore = nextCompletedMaxScore ?? completedMaxScore ?? 1;
136
138
  const score = nextCompletedScore ?? completedScore ?? maxScore;
137
- replayTelemetry(nextSelected ?? null, nextCorrect ?? null, nextPassed, score, maxScore);
139
+ if (config.tracking?.replayResumeEvents === true) {
140
+ replayTelemetry(nextSelected ?? null, nextCorrect ?? null, nextPassed, score, maxScore);
141
+ }
138
142
  }
139
143
  }
140
144
  readBooleanStateField(state, "showSolutions", setShowSolutions);
@@ -150,7 +154,8 @@ function TrueFalseInner(props, ref) {
150
154
  props.question,
151
155
  selected,
152
156
  selectionCorrect,
153
- showSolutions
157
+ showSolutions,
158
+ config.tracking?.replayResumeEvents
154
159
  ]
155
160
  );
156
161
  useAssessmentHandleRegistration(checkId, handle, ref);
@@ -179,6 +184,17 @@ function TrueFalseInner(props, ref) {
179
184
  maxScore: scored.maxScore,
180
185
  passingScore: props.passingScore ?? scored.maxScore
181
186
  });
187
+ } else if (!scored.passed && props.enableRetry === false && !completedRef.current) {
188
+ completedRef.current = true;
189
+ setCompletedScore(scored.score);
190
+ setCompletedMaxScore(scored.maxScore);
191
+ assessment.complete({
192
+ checkId,
193
+ interactionType: INTERACTION,
194
+ score: scored.score,
195
+ maxScore: scored.maxScore,
196
+ passingScore: props.passingScore ?? scored.maxScore
197
+ });
182
198
  }
183
199
  };
184
200
  const reveal = showSolutions || passed && props.enableSolutionsButton;
@@ -244,11 +260,13 @@ function MarkTheWordsInner(props, ref) {
244
260
  );
245
261
  const [marked, setMarked] = useState2(() => /* @__PURE__ */ new Set());
246
262
  const [passed, setPassed] = useState2(false);
263
+ const [submitted, setSubmitted] = useState2(false);
247
264
  const [showSolutions, setShowSolutions] = useState2(false);
248
265
  const completedRef = useRef2(false);
249
266
  const reset = () => {
250
267
  completedRef.current = false;
251
268
  setPassed(false);
269
+ setSubmitted(false);
252
270
  setMarked(/* @__PURE__ */ new Set());
253
271
  setShowSolutions(false);
254
272
  };
@@ -306,6 +324,27 @@ function MarkTheWordsInner(props, ref) {
306
324
  return next;
307
325
  });
308
326
  };
327
+ const submitMarks = () => {
328
+ if (!hasTargets || completedRef.current || marked.size === 0) return;
329
+ completedRef.current = true;
330
+ setSubmitted(true);
331
+ const didPass = passedThreshold;
332
+ if (didPass) setPassed(true);
333
+ assessment.answer({
334
+ checkId,
335
+ interactionType: INTERACTION2,
336
+ question: props.text,
337
+ response: [...marked].map((i) => tokens[i]),
338
+ correct: didPass
339
+ });
340
+ assessment.complete({
341
+ checkId,
342
+ interactionType: INTERACTION2,
343
+ score,
344
+ maxScore,
345
+ passingScore: props.passingScore ?? maxScore
346
+ });
347
+ };
309
348
  useEffect2(() => {
310
349
  if (!hasTargets) {
311
350
  if (isDevEnvironment()) {
@@ -316,8 +355,10 @@ function MarkTheWordsInner(props, ref) {
316
355
  }
317
356
  return;
318
357
  }
358
+ if (props.enableRetry === false) return;
319
359
  if (!passedThreshold || completedRef.current) return;
320
360
  completedRef.current = true;
361
+ setSubmitted(true);
321
362
  setPassed(true);
322
363
  assessment.answer({
323
364
  checkId,
@@ -340,6 +381,7 @@ function MarkTheWordsInner(props, ref) {
340
381
  marked,
341
382
  maxScore,
342
383
  passedThreshold,
384
+ props.enableRetry,
343
385
  props.passingScore,
344
386
  props.correctWords,
345
387
  props.text,
@@ -377,7 +419,8 @@ function MarkTheWordsInner(props, ref) {
377
419
  i
378
420
  );
379
421
  }) }),
380
- allMarked ? /* @__PURE__ */ jsx2("p", { role: "status", "aria-live": "polite", children: "Correct" }) : null,
422
+ passedThreshold ? /* @__PURE__ */ jsx2("p", { role: "status", "aria-live": "polite", children: "Correct" }) : null,
423
+ props.enableRetry === false && hasTargets && marked.size > 0 && !submitted ? /* @__PURE__ */ jsx2("button", { type: "button", "data-testid": "mark-the-words-submit", onClick: submitMarks, children: "Submit" }) : null,
381
424
  props.enableRetry && passed ? /* @__PURE__ */ jsx2("button", { type: "button", onClick: reset, children: "Try again" }) : null,
382
425
  props.enableSolutionsButton && !showSolutions ? /* @__PURE__ */ jsx2("button", { type: "button", onClick: () => setShowSolutions(true), children: "Show solution" }) : null
383
426
  ] });
@@ -388,7 +431,7 @@ var MarkTheWords = forwardRef2(function MarkTheWords2(props, ref) {
388
431
  });
389
432
 
390
433
  // src/blocks/FillInTheBlanks.tsx
391
- import React3, { forwardRef as forwardRef3, useEffect as useEffect3, useMemo as useMemo3, useRef as useRef3, useState as useState3 } from "react";
434
+ import React3, { forwardRef as forwardRef3, useCallback, useEffect as useEffect3, useMemo as useMemo3, useRef as useRef3, useState as useState3 } from "react";
392
435
 
393
436
  // src/assessment/internal/parseStarDelimitedTemplate.ts
394
437
  function parseStarDelimitedTemplate(template, idPrefix) {
@@ -418,11 +461,43 @@ function parseTemplate(template) {
418
461
  blanks: values.map((answer, i) => ({ id: `blank-${i}`, answer }))
419
462
  };
420
463
  }
464
+ function normalizeBlanks(blanks) {
465
+ return blanks.map((b) => ({ id: b.id.trim(), answer: b.answer.trim() })).filter((b) => b.id.length > 0 && b.answer.length > 0);
466
+ }
467
+ function resolveBlanks(template, explicitBlanks) {
468
+ const parsed = parseTemplate(template);
469
+ if (!explicitBlanks) {
470
+ return { parts: parsed.parts, blanks: parsed.blanks };
471
+ }
472
+ const normalized = normalizeBlanks(explicitBlanks);
473
+ if (normalized.length !== parsed.blanks.length) {
474
+ if (isDevEnvironment()) {
475
+ console.warn(
476
+ "[lessonkit] FillInTheBlanks: blanks length does not match template; using parsed blanks",
477
+ { templateBlanks: parsed.blanks.length, explicitBlanks: normalized.length }
478
+ );
479
+ }
480
+ return { parts: parsed.parts, blanks: parsed.blanks };
481
+ }
482
+ let blankIdx = 0;
483
+ const interleavedParts = parsed.parts.map((part) => {
484
+ if (part.startsWith("blank-")) {
485
+ const id = normalized[blankIdx]?.id;
486
+ blankIdx += 1;
487
+ return id ?? part;
488
+ }
489
+ return part;
490
+ });
491
+ return { parts: interleavedParts, blanks: normalized };
492
+ }
421
493
  function FillInTheBlanksInner(props, ref) {
422
494
  const checkId = useMemo3(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
423
495
  const assessment = useAssessmentState(props.enclosingLessonId);
424
- const parsed = useMemo3(() => parseTemplate(props.template), [props.template]);
425
- const blanks = props.blanks ?? parsed.blanks;
496
+ const { config } = useLessonkit();
497
+ const { parts, blanks } = useMemo3(
498
+ () => resolveBlanks(props.template, props.blanks),
499
+ [props.template, props.blanks]
500
+ );
426
501
  const [values, setValues] = useState3(
427
502
  () => Object.fromEntries(blanks.map((b) => [b.id, ""]))
428
503
  );
@@ -521,13 +596,14 @@ function FillInTheBlanksInner(props, ref) {
521
596
  blanks.forEach((b) => {
522
597
  if ((nextValues[b.id] ?? "").trim().toLowerCase() === b.answer.toLowerCase()) nextScore += 1;
523
598
  });
524
- replayTelemetry(nextValues, nextPassed, nextSubmitted, nextScore, blanks.length);
599
+ if (config.tracking?.replayResumeEvents === true) {
600
+ replayTelemetry(nextValues, nextPassed, nextSubmitted, nextScore, blanks.length);
601
+ }
525
602
  }
526
603
  }),
527
- [allFilled, assessment, blanks, checkId, maxScore, passed, passedThreshold, props.passingScore, props.template, score, showSolutions, submitted, values]
604
+ [allFilled, assessment, blanks, checkId, config.tracking?.replayResumeEvents, maxScore, passed, passedThreshold, props.passingScore, props.template, score, showSolutions, submitted, values]
528
605
  );
529
- useAssessmentHandleRegistration(checkId, handle, ref);
530
- const check = () => {
606
+ const check = useCallback(() => {
531
607
  if (!hasBlanks) {
532
608
  if (isDevEnvironment()) {
533
609
  console.warn("[lessonkit] FillInTheBlanks has no blanks in template");
@@ -535,7 +611,7 @@ function FillInTheBlanksInner(props, ref) {
535
611
  return;
536
612
  }
537
613
  if (!allFilled) return;
538
- if (passed) return;
614
+ if (passed && !props.enableRetry) return;
539
615
  const snapshot = JSON.stringify(values);
540
616
  if (checkSnapshotRef.current === snapshot) return;
541
617
  checkSnapshotRef.current = snapshot;
@@ -548,9 +624,9 @@ function FillInTheBlanksInner(props, ref) {
548
624
  response: values,
549
625
  correct: passedThreshold
550
626
  });
551
- if (passedThreshold && !completedRef.current) {
627
+ if ((passedThreshold || props.enableRetry === false) && !completedRef.current) {
552
628
  completedRef.current = true;
553
- setPassed(true);
629
+ if (passedThreshold) setPassed(true);
554
630
  assessment.complete({
555
631
  checkId,
556
632
  interactionType: INTERACTION3,
@@ -559,7 +635,22 @@ function FillInTheBlanksInner(props, ref) {
559
635
  passingScore: props.passingScore ?? maxScore
560
636
  });
561
637
  }
562
- };
638
+ }, [
639
+ allFilled,
640
+ assessment,
641
+ blanks.length,
642
+ checkId,
643
+ hasBlanks,
644
+ maxScore,
645
+ passed,
646
+ passedThreshold,
647
+ props.enableRetry,
648
+ props.passingScore,
649
+ props.template,
650
+ score,
651
+ values
652
+ ]);
653
+ useAssessmentHandleRegistration(checkId, handle, ref);
563
654
  useEffect3(() => {
564
655
  if (!allFilled) {
565
656
  answeredRef.current = false;
@@ -569,14 +660,17 @@ function FillInTheBlanksInner(props, ref) {
569
660
  }, [allFilled]);
570
661
  useEffect3(() => {
571
662
  if (props.autoCheck && allFilled && !passed) check();
572
- }, [allFilled, props.autoCheck, values, passedThreshold, passed]);
663
+ }, [allFilled, check, passed, props.autoCheck]);
573
664
  const reveal = showSolutions || passed && props.enableSolutionsButton;
574
665
  return /* @__PURE__ */ jsxs3("section", { "aria-label": "Fill in the Blanks", "data-lk-check-id": checkId, children: [
575
- /* @__PURE__ */ jsx3("p", { children: parsed.parts.map((part, i) => {
666
+ /* @__PURE__ */ jsx3("p", { children: parts.map((part, i) => {
576
667
  const blank = blanks.find((b) => b.id === part);
577
668
  if (!blank) return /* @__PURE__ */ jsx3(React3.Fragment, { children: part }, i);
578
669
  return /* @__PURE__ */ jsxs3("label", { style: { margin: "0 0.25em" }, children: [
579
- /* @__PURE__ */ jsx3("span", { className: "lk-visually-hidden", children: blank.answer }),
670
+ /* @__PURE__ */ jsxs3("span", { className: "lk-visually-hidden", children: [
671
+ "Blank ",
672
+ blank.id
673
+ ] }),
580
674
  /* @__PURE__ */ jsx3(
581
675
  "input",
582
676
  {
@@ -593,7 +687,16 @@ function FillInTheBlanksInner(props, ref) {
593
687
  )
594
688
  ] }, blank.id);
595
689
  }) }),
596
- !props.autoCheck ? /* @__PURE__ */ jsx3("button", { type: "button", "data-testid": "check-blanks", disabled: !allFilled || passed, onClick: check, children: "Check" }) : null,
690
+ !props.autoCheck ? /* @__PURE__ */ jsx3(
691
+ "button",
692
+ {
693
+ type: "button",
694
+ "data-testid": "check-blanks",
695
+ disabled: !allFilled || passed && !props.enableRetry,
696
+ onClick: check,
697
+ children: "Check"
698
+ }
699
+ ) : null,
597
700
  !hasBlanks ? /* @__PURE__ */ jsx3("p", { role: "alert", children: "This activity has no blanks. Add text wrapped in asterisks, e.g. The *answer* here." }) : null,
598
701
  submitted ? /* @__PURE__ */ jsx3("p", { role: "status", "aria-live": "polite", children: passed || passedThreshold ? "Correct" : "Try again" }) : null,
599
702
  props.enableRetry && passed ? /* @__PURE__ */ jsx3("button", { type: "button", onClick: reset, children: "Try again" }) : null,
@@ -618,6 +721,7 @@ function parseZones(template) {
618
721
  function DragTheWordsInner(props, ref) {
619
722
  const checkId = useMemo4(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
620
723
  const assessment = useAssessmentState(props.enclosingLessonId);
724
+ const { config } = useLessonkit();
621
725
  const { parts, answers } = useMemo4(() => parseZones(props.template), [props.template]);
622
726
  const [zones, setZones] = useState4(
623
727
  () => Object.fromEntries(answers.map((_, i) => [`zone-${i}`, ""]))
@@ -722,10 +826,12 @@ function DragTheWordsInner(props, ref) {
722
826
  answers.forEach((ans, i) => {
723
827
  if ((nextZones[`zone-${i}`] ?? "").trim().toLowerCase() === ans.toLowerCase()) nextScore += 1;
724
828
  });
725
- replayTelemetry(nextZones, nextPassed, nextSubmitted, nextScore, answers.length);
829
+ if (config.tracking?.replayResumeEvents === true) {
830
+ replayTelemetry(nextZones, nextPassed, nextSubmitted, nextScore, answers.length);
831
+ }
726
832
  }
727
833
  }),
728
- [allFilled, answers, assessment, checkId, keyboardWord, maxScore, passed, passedThreshold, pool, props.passingScore, props.template, score, submitted, zones]
834
+ [allFilled, answers, assessment, checkId, config.tracking?.replayResumeEvents, keyboardWord, maxScore, passed, passedThreshold, pool, props.passingScore, props.template, score, submitted, zones]
729
835
  );
730
836
  useAssessmentHandleRegistration(checkId, handle, ref);
731
837
  const placeInZone = (zoneId, word) => {
@@ -768,9 +874,9 @@ function DragTheWordsInner(props, ref) {
768
874
  response: zones,
769
875
  correct: passedThreshold
770
876
  });
771
- if (passedThreshold && !completedRef.current) {
877
+ if ((passedThreshold || props.enableRetry === false) && !completedRef.current) {
772
878
  completedRef.current = true;
773
- setPassed(true);
879
+ if (passedThreshold) setPassed(true);
774
880
  assessment.complete({
775
881
  checkId,
776
882
  interactionType: INTERACTION4,
@@ -832,7 +938,16 @@ function DragTheWordsInner(props, ref) {
832
938
  part
833
939
  );
834
940
  }) }),
835
- /* @__PURE__ */ jsx4("button", { type: "button", "data-testid": "check-drag-words", disabled: !allFilled || passed, onClick: check, children: "Check" }),
941
+ /* @__PURE__ */ jsx4(
942
+ "button",
943
+ {
944
+ type: "button",
945
+ "data-testid": "check-drag-words",
946
+ disabled: !allFilled || passed && !props.enableRetry,
947
+ onClick: check,
948
+ children: "Check"
949
+ }
950
+ ),
836
951
  !hasZones ? /* @__PURE__ */ jsx4("p", { role: "alert", children: "This activity has no drop zones. Wrap answers in asterisks in the template." }) : null,
837
952
  submitted ? /* @__PURE__ */ jsx4("p", { role: "status", "aria-live": "polite", children: passed || passedThreshold ? "Correct" : "Try again" }) : null
838
953
  ] });
@@ -846,6 +961,37 @@ var DragTheWords = forwardRef4(function DragTheWords2(props, ref) {
846
961
  import { forwardRef as forwardRef5, useEffect as useEffect5, useMemo as useMemo5, useRef as useRef5, useState as useState5 } from "react";
847
962
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
848
963
  var INTERACTION5 = "dragAndDrop";
964
+ function normalizeDragAndDropState(rawAssignments, rawPool, items, targets) {
965
+ const itemIds = new Set(items.map((i) => i.id));
966
+ const targetIds = targets.map((t) => t.id);
967
+ const assignments = Object.fromEntries(targetIds.map((id) => [id, ""]));
968
+ if (rawAssignments && typeof rawAssignments === "object") {
969
+ for (const targetId of targetIds) {
970
+ const value = rawAssignments[targetId];
971
+ if (typeof value === "string" && (value === "" || itemIds.has(value))) {
972
+ assignments[targetId] = value;
973
+ }
974
+ }
975
+ }
976
+ const assigned = new Set(Object.values(assignments).filter(Boolean));
977
+ const pool = [];
978
+ const seen = /* @__PURE__ */ new Set();
979
+ if (Array.isArray(rawPool)) {
980
+ for (const id of rawPool) {
981
+ if (typeof id === "string" && itemIds.has(id) && !assigned.has(id) && !seen.has(id)) {
982
+ pool.push(id);
983
+ seen.add(id);
984
+ }
985
+ }
986
+ }
987
+ for (const item of items) {
988
+ if (!assigned.has(item.id) && !seen.has(item.id)) {
989
+ pool.push(item.id);
990
+ seen.add(item.id);
991
+ }
992
+ }
993
+ return { assignments, pool };
994
+ }
849
995
  function DragAndDropInner(props, ref) {
850
996
  const checkId = useMemo5(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
851
997
  const assessment = useAssessmentState(props.enclosingLessonId);
@@ -895,11 +1041,14 @@ function DragAndDropInner(props, ref) {
895
1041
  }),
896
1042
  getCurrentState: () => ({ assignments, pool, passed, checked, keyboardItem }),
897
1043
  resume: (state) => {
898
- const rawAssignments = state.assignments;
899
- if (rawAssignments && typeof rawAssignments === "object") {
900
- setAssignments({ ...rawAssignments });
901
- }
902
- if (Array.isArray(state.pool)) setPool([...state.pool]);
1044
+ const normalized = normalizeDragAndDropState(
1045
+ state.assignments,
1046
+ state.pool,
1047
+ props.items,
1048
+ props.targets
1049
+ );
1050
+ setAssignments(normalized.assignments);
1051
+ setPool(normalized.pool);
903
1052
  readBooleanStateField(state, "passed", (value) => {
904
1053
  setPassed(value);
905
1054
  completedRef.current = value;
@@ -932,9 +1081,9 @@ function DragAndDropInner(props, ref) {
932
1081
  response: assignments,
933
1082
  correct: passedThreshold
934
1083
  });
935
- if (passedThreshold && !completedRef.current) {
1084
+ if ((passedThreshold || props.enableRetry === false) && !completedRef.current) {
936
1085
  completedRef.current = true;
937
- setPassed(true);
1086
+ if (passedThreshold) setPassed(true);
938
1087
  assessment.complete({
939
1088
  checkId,
940
1089
  interactionType: INTERACTION5,
@@ -997,7 +1146,16 @@ function DragAndDropInner(props, ref) {
997
1146
  )
998
1147
  ] }, target.id);
999
1148
  }) }),
1000
- /* @__PURE__ */ jsx5("button", { type: "button", "data-testid": "check-drag-drop", disabled: !hasTargets || !allFilled || passed, onClick: check, children: "Check" }),
1149
+ /* @__PURE__ */ jsx5(
1150
+ "button",
1151
+ {
1152
+ type: "button",
1153
+ "data-testid": "check-drag-drop",
1154
+ disabled: !hasTargets || !allFilled || passed && !props.enableRetry,
1155
+ onClick: check,
1156
+ children: "Check"
1157
+ }
1158
+ ),
1001
1159
  checked ? /* @__PURE__ */ jsx5("p", { role: "status", "aria-live": "polite", children: passedThreshold ? "Correct" : "Try again" }) : null
1002
1160
  ] });
1003
1161
  }
@@ -1007,7 +1165,7 @@ var DragAndDrop = forwardRef5(function DragAndDrop2(props, ref) {
1007
1165
  });
1008
1166
 
1009
1167
  // src/blocks/AssessmentSequence.tsx
1010
- import React7, { forwardRef as forwardRef6, useCallback as useCallback4, useEffect as useEffect8, useId, useMemo as useMemo7, useRef as useRef8, useState as useState6 } from "react";
1168
+ import React7, { forwardRef as forwardRef6, useCallback as useCallback5, useEffect as useEffect8, useId, useMemo as useMemo7, useRef as useRef8, useState as useState6 } from "react";
1011
1169
  import { deriveId } from "@lessonkit/core";
1012
1170
 
1013
1171
  // src/compound/useCompoundShell.ts
@@ -1015,13 +1173,13 @@ import { useMemo as useMemo6 } from "react";
1015
1173
  import { clampCompoundPageIndex as clampCompoundPageIndex2 } from "@lessonkit/core";
1016
1174
 
1017
1175
  // src/compound/useCompoundNavigation.ts
1018
- import { useCallback } from "react";
1176
+ import { useCallback as useCallback2 } from "react";
1019
1177
  function useCompoundNavigation(pageCount, index, setIndex) {
1020
- const goNext = useCallback(() => {
1178
+ const goNext = useCallback2(() => {
1021
1179
  if (pageCount < 1) return;
1022
1180
  setIndex((i) => Math.min(i + 1, pageCount - 1));
1023
1181
  }, [pageCount, setIndex]);
1024
- const goPrev = useCallback(() => {
1182
+ const goPrev = useCallback2(() => {
1025
1183
  setIndex((i) => Math.max(i - 1, 0));
1026
1184
  }, [setIndex]);
1027
1185
  const clampedIndex = pageCount < 1 ? 0 : Math.min(index, pageCount - 1);
@@ -1035,34 +1193,138 @@ function useCompoundNavigation(pageCount, index, setIndex) {
1035
1193
  }
1036
1194
 
1037
1195
  // src/compound/useCompoundPersistence.ts
1038
- import { useCallback as useCallback3, useContext as useContext2, useEffect as useEffect7, useRef as useRef7 } from "react";
1196
+ import { useCallback as useCallback4, useContext as useContext2, useEffect as useEffect7, useLayoutEffect, useRef as useRef7 } from "react";
1039
1197
  import {
1040
1198
  clampCompoundPageIndex,
1041
- createCompoundResumeState,
1199
+ createCompoundResumeState as createCompoundResumeState3,
1042
1200
  createSessionStoragePort as createSessionStoragePort2,
1043
1201
  loadCompoundState as loadCompoundState2
1044
1202
  } from "@lessonkit/core";
1045
1203
 
1204
+ // src/compound/compoundHydration.ts
1205
+ var hydratedKeys = /* @__PURE__ */ new Set();
1206
+ function compoundHydrationKey(courseId, compoundId) {
1207
+ return `${courseId}:${compoundId}`;
1208
+ }
1209
+ function markCompoundHydrated(key) {
1210
+ hydratedKeys.add(key);
1211
+ }
1212
+ function isCompoundHydrated(key) {
1213
+ return hydratedKeys.has(key);
1214
+ }
1215
+ function clearCompoundHydrated(key) {
1216
+ hydratedKeys.delete(key);
1217
+ }
1218
+
1219
+ // src/compound/useCompoundBranchShell.ts
1220
+ import "@lessonkit/core";
1221
+ var BS_META_KEY = "__lk_bs__";
1222
+ function createInitialBranchMeta(startNodeId) {
1223
+ return { activeNodeId: startNodeId, visitedNodeIds: [startNodeId] };
1224
+ }
1225
+ function readBranchingScenarioMeta(childStates) {
1226
+ const raw = childStates[BS_META_KEY];
1227
+ if (!raw || typeof raw !== "object") return null;
1228
+ const activeNodeId = typeof raw.activeNodeId === "string" ? raw.activeNodeId : "";
1229
+ const visitedNodeIds = Array.isArray(raw.visitedNodeIds) ? raw.visitedNodeIds.filter((id) => typeof id === "string") : [];
1230
+ const choiceScores = raw.choiceScores && typeof raw.choiceScores === "object" && !Array.isArray(raw.choiceScores) ? raw.choiceScores : void 0;
1231
+ if (!activeNodeId) return null;
1232
+ return { activeNodeId, visitedNodeIds, choiceScores };
1233
+ }
1234
+ function sanitizeBranchMeta(meta, nodeIndexMap, startNodeId, validChoiceKeys) {
1235
+ const knownIds = new Set(nodeIndexMap.keys());
1236
+ const activeNodeId = knownIds.has(meta.activeNodeId) ? meta.activeNodeId : startNodeId;
1237
+ const visitedNodeIds = meta.visitedNodeIds.filter((id) => knownIds.has(id));
1238
+ if (!visitedNodeIds.includes(activeNodeId)) {
1239
+ visitedNodeIds.push(activeNodeId);
1240
+ }
1241
+ if (visitedNodeIds.length === 0) {
1242
+ visitedNodeIds.push(startNodeId);
1243
+ }
1244
+ const choiceScores = meta.choiceScores ? Object.fromEntries(
1245
+ Object.entries(meta.choiceScores).filter(
1246
+ ([key]) => validChoiceKeys ? validChoiceKeys.has(key) : knownIds.has(key.split(":")[0] ?? "")
1247
+ )
1248
+ ) : void 0;
1249
+ return {
1250
+ activeNodeId,
1251
+ visitedNodeIds,
1252
+ ...Object.keys(choiceScores ?? {}).length > 0 ? { choiceScores } : {}
1253
+ };
1254
+ }
1255
+ function mergeBranchMetaIntoState(state, meta) {
1256
+ return {
1257
+ ...state,
1258
+ childStates: {
1259
+ ...state.childStates,
1260
+ [BS_META_KEY]: meta
1261
+ }
1262
+ };
1263
+ }
1264
+ function choiceScoreKey(fromNodeId, toNodeId) {
1265
+ return `${fromNodeId}:${toNodeId}`;
1266
+ }
1267
+ function applyChoiceScoreUpdate(prev, fromNodeId, toNodeId, scoreWeight) {
1268
+ if (scoreWeight === void 0) return prev;
1269
+ const next = { ...prev ?? {} };
1270
+ for (const key of Object.keys(next)) {
1271
+ if (key.startsWith(`${fromNodeId}:`)) {
1272
+ delete next[key];
1273
+ }
1274
+ }
1275
+ next[choiceScoreKey(fromNodeId, toNodeId)] = scoreWeight;
1276
+ return next;
1277
+ }
1278
+ function sumChoiceScores(choiceScores) {
1279
+ if (!choiceScores) return 0;
1280
+ return Object.values(choiceScores).reduce((sum, value) => sum + (Number.isFinite(value) ? value : 0), 0);
1281
+ }
1282
+
1283
+ // src/compound/useCompoundVideoShell.ts
1284
+ import "@lessonkit/core";
1285
+ var IV_META_KEY = "__lk_iv__";
1286
+ function readInteractiveVideoMeta(childStates) {
1287
+ const raw = childStates[IV_META_KEY];
1288
+ if (!raw || typeof raw !== "object") return null;
1289
+ const currentTime = typeof raw.currentTime === "number" ? raw.currentTime : 0;
1290
+ const completedCueIndices = Array.isArray(raw.completedCueIndices) ? raw.completedCueIndices.filter((n) => typeof n === "number") : [];
1291
+ const firedCueIndices = Array.isArray(raw.firedCueIndices) ? raw.firedCueIndices.filter((n) => typeof n === "number") : completedCueIndices;
1292
+ return { currentTime, completedCueIndices, firedCueIndices };
1293
+ }
1294
+ function mergeVideoMetaIntoState(state, meta) {
1295
+ return {
1296
+ ...state,
1297
+ childStates: {
1298
+ ...state.childStates,
1299
+ [IV_META_KEY]: meta
1300
+ }
1301
+ };
1302
+ }
1303
+
1046
1304
  // src/compound/resumeChildHandles.ts
1047
- function filterRegisteredChildStates(handles, childStates) {
1305
+ var DEFAULT_PRESERVED_CHILD_STATE_KEYS = /* @__PURE__ */ new Set([BS_META_KEY, IV_META_KEY]);
1306
+ function registerablePendingKeys(childStates, preserveKeys = DEFAULT_PRESERVED_CHILD_STATE_KEYS) {
1307
+ return Object.keys(childStates).filter((key) => !preserveKeys.has(key));
1308
+ }
1309
+ function filterRegisteredChildStates(handles, childStates, preserveKeys = DEFAULT_PRESERVED_CHILD_STATE_KEYS) {
1048
1310
  const filtered = {};
1049
1311
  for (const [key, value] of Object.entries(childStates)) {
1050
- if (handles.has(key)) {
1312
+ if (preserveKeys.has(key) || handles.has(key)) {
1051
1313
  filtered[key] = value;
1052
1314
  }
1053
1315
  }
1054
1316
  return filtered;
1055
1317
  }
1056
1318
  function resumeChildHandles(handles, childStates, opts) {
1057
- const pendingKeys = Object.keys(childStates);
1058
1319
  const alreadyResumed = opts?.alreadyResumed;
1059
- if (opts?.waitForHandles && pendingKeys.length > 0) {
1320
+ const registerableKeys = registerablePendingKeys(childStates);
1321
+ if (opts?.waitForHandles && registerableKeys.length > 0) {
1060
1322
  if (handles.size === 0) return false;
1061
- const registeredPending = pendingKeys.filter((k) => handles.has(k));
1323
+ const registeredPending = registerableKeys.filter((k) => handles.has(k));
1062
1324
  if (registeredPending.length === 0) {
1063
1325
  return false;
1064
1326
  }
1065
- if (registeredPending.length < pendingKeys.length) {
1327
+ if (registeredPending.length < registerableKeys.length) {
1066
1328
  for (const key of registeredPending) {
1067
1329
  if (alreadyResumed?.has(key)) continue;
1068
1330
  const handle = handles.get(key);
@@ -1087,15 +1349,27 @@ function resumeChildHandles(handles, childStates, opts) {
1087
1349
  }
1088
1350
 
1089
1351
  // src/compound/useCompoundResume.ts
1090
- import { useCallback as useCallback2, useContext, useEffect as useEffect6, useRef as useRef6 } from "react";
1352
+ import { useCallback as useCallback3, useContext, useEffect as useEffect6, useRef as useRef6 } from "react";
1091
1353
  import { loadCompoundState, saveCompoundState } from "@lessonkit/core";
1092
1354
  import { createSessionStoragePort } from "@lessonkit/core";
1093
- var warnedCompoundPersistFailure = false;
1094
- function warnCompoundPersistFailure() {
1095
- if (warnedCompoundPersistFailure || !isDevEnvironment()) return;
1096
- warnedCompoundPersistFailure = true;
1355
+
1356
+ // src/compound/compoundLoadOpts.ts
1357
+ function compoundLoadOpts(ctx, compoundId) {
1358
+ const onCorruptHook = ctx?.config?.observability?.onCompoundResumeCorrupt;
1359
+ if (!onCorruptHook) return void 0;
1360
+ return {
1361
+ onCorrupt: () => onCorruptHook({ compoundId, corrupt: true }),
1362
+ onDroppedChildKeys: (droppedChildKeys) => onCorruptHook({ compoundId, droppedChildKeys })
1363
+ };
1364
+ }
1365
+
1366
+ // src/compound/useCompoundResume.ts
1367
+ var warnedCompoundPersistFailures = /* @__PURE__ */ new Set();
1368
+ function warnCompoundPersistFailure(compoundId) {
1369
+ if (warnedCompoundPersistFailures.has(compoundId) || !isDevEnvironment()) return;
1370
+ warnedCompoundPersistFailures.add(compoundId);
1097
1371
  console.warn(
1098
- "[lessonkit] compound resume state could not be saved to sessionStorage (quota or privacy mode); progress may be lost on reload."
1372
+ `[lessonkit] compound resume state for "${compoundId}" could not be saved to sessionStorage (quota or privacy mode); progress may be lost on reload.`
1099
1373
  );
1100
1374
  }
1101
1375
  function useCompoundResume(opts) {
@@ -1118,23 +1392,37 @@ function useCompoundResume(opts) {
1118
1392
  resumedRef.current = false;
1119
1393
  }
1120
1394
  if (!opts.enabled || !opts.courseId || resumedRef.current) return;
1121
- const saved = loadCompoundState(storageRef.current, opts.courseId, opts.compoundId);
1395
+ if (isCompoundHydrated(key)) {
1396
+ resumedRef.current = true;
1397
+ return;
1398
+ }
1399
+ const saved = loadCompoundState(
1400
+ storageRef.current,
1401
+ opts.courseId,
1402
+ opts.compoundId,
1403
+ compoundLoadOpts(lessonkitCtx, opts.compoundId)
1404
+ );
1122
1405
  if (saved) {
1123
1406
  resumedRef.current = true;
1124
1407
  opts.onResume?.(saved);
1125
1408
  }
1126
1409
  }, [opts.enabled, opts.courseId, opts.compoundId, opts.onResume]);
1127
- return useCallback2(
1410
+ return useCallback3(
1128
1411
  (state) => {
1129
1412
  if (!opts.enabled || !opts.courseId) return;
1130
1413
  const persisted = saveCompoundState(storageRef.current, opts.courseId, opts.compoundId, state);
1131
- if (!persisted) warnCompoundPersistFailure();
1414
+ if (!persisted) warnCompoundPersistFailure(opts.compoundId);
1132
1415
  },
1133
1416
  [opts.enabled, opts.courseId, opts.compoundId]
1134
1417
  );
1135
1418
  }
1136
1419
 
1137
1420
  // src/compound/useCompoundPersistence.ts
1421
+ var MAX_HYDRATION_RETRIES = 10;
1422
+ function isEmptyResumeState(state) {
1423
+ if (!state || typeof state !== "object") return true;
1424
+ return Object.keys(state).length === 0;
1425
+ }
1138
1426
  function readCompoundInitialIndex(courseId, compoundId, pageCount, enabled, storage = createSessionStoragePort2()) {
1139
1427
  if (!enabled || !courseId || pageCount < 1) return 0;
1140
1428
  const saved = loadCompoundState2(storage, courseId, compoundId);
@@ -1154,51 +1442,60 @@ function useCompoundPersistence(opts) {
1154
1442
  const resumedChildKeysRef = useRef7(/* @__PURE__ */ new Set());
1155
1443
  const loadedChildStatesRef = useRef7({});
1156
1444
  const skipSaveUntilHydratedRef = useRef7(false);
1445
+ const postResumePersistPendingRef = useRef7(false);
1157
1446
  const hydrationKeyRef = useRef7("");
1158
- const hydrationInitRef = useRef7(false);
1447
+ const hydrationRetryRef = useRef7(0);
1159
1448
  const hydrationKey = `${opts.courseId ?? ""}:${opts.compoundId}`;
1160
1449
  if (hydrationKeyRef.current !== hydrationKey) {
1450
+ if (hydrationKeyRef.current) {
1451
+ clearCompoundHydrated(hydrationKeyRef.current);
1452
+ }
1161
1453
  hydrationKeyRef.current = hydrationKey;
1162
- hydrationInitRef.current = false;
1163
1454
  loadedChildStatesRef.current = {};
1164
1455
  skipSaveUntilHydratedRef.current = false;
1456
+ postResumePersistPendingRef.current = false;
1165
1457
  pendingChildResumeRef.current = null;
1166
1458
  resumedChildKeysRef.current = /* @__PURE__ */ new Set();
1459
+ hydrationRetryRef.current = 0;
1167
1460
  }
1168
- if (!hydrationInitRef.current && opts.enabled && opts.courseId) {
1169
- hydrationInitRef.current = true;
1170
- const saved = loadCompoundState2(storage, opts.courseId, opts.compoundId);
1171
- if (saved && Object.keys(saved.childStates).length > 0) {
1172
- loadedChildStatesRef.current = { ...saved.childStates };
1173
- skipSaveUntilHydratedRef.current = true;
1174
- pendingChildResumeRef.current = saved;
1175
- }
1176
- }
1177
- const buildState = useCallback3(() => {
1461
+ const buildState = useCallback4(() => {
1178
1462
  const childStates = {
1179
1463
  ...loadedChildStatesRef.current
1180
1464
  };
1181
1465
  if (ctx) {
1182
1466
  for (const [checkId, entry] of ctx.getRegisteredHandles()) {
1467
+ if (opts.shouldIncludeChildState && !opts.shouldIncludeChildState(checkId, entry.pageIndex)) {
1468
+ continue;
1469
+ }
1183
1470
  const handle = entry.handle;
1184
1471
  if (handle.getCurrentState) {
1185
- childStates[checkId] = handle.getCurrentState();
1186
- delete loadedChildStatesRef.current[checkId];
1472
+ const live = handle.getCurrentState();
1473
+ const loaded = loadedChildStatesRef.current[checkId];
1474
+ if (loaded !== void 0 && isEmptyResumeState(live)) {
1475
+ childStates[checkId] = loaded;
1476
+ } else {
1477
+ childStates[checkId] = live;
1478
+ if (!isEmptyResumeState(live)) {
1479
+ delete loadedChildStatesRef.current[checkId];
1480
+ }
1481
+ }
1187
1482
  }
1188
1483
  }
1189
1484
  }
1190
- return createCompoundResumeState({
1485
+ return createCompoundResumeState3({
1191
1486
  activePageIndex: clampCompoundPageIndex(opts.index, opts.pageCount),
1192
1487
  childStates
1193
1488
  });
1194
- }, [ctx, opts.index, opts.pageCount]);
1489
+ }, [ctx, opts.index, opts.pageCount, opts.shouldIncludeChildState]);
1195
1490
  const buildStateRef = useRef7(buildState);
1196
1491
  buildStateRef.current = buildState;
1197
1492
  const transformStateRef = useRef7(opts.transformState);
1198
1493
  transformStateRef.current = opts.transformState;
1494
+ const onCompoundResumeRef = useRef7(opts.onCompoundResume);
1495
+ onCompoundResumeRef.current = opts.onCompoundResume;
1199
1496
  const persistNowRef = useRef7(() => {
1200
1497
  });
1201
- const finalizeHydration = useCallback3(
1498
+ const finalizeHydration = useCallback4(
1202
1499
  (childStates) => {
1203
1500
  loadedChildStatesRef.current = {
1204
1501
  ...loadedChildStatesRef.current,
@@ -1206,11 +1503,20 @@ function useCompoundPersistence(opts) {
1206
1503
  };
1207
1504
  skipSaveUntilHydratedRef.current = false;
1208
1505
  pendingChildResumeRef.current = null;
1209
- queueMicrotask(() => persistNowRef.current());
1506
+ hydrationRetryRef.current = 0;
1507
+ postResumePersistPendingRef.current = true;
1508
+ requestAnimationFrame(() => {
1509
+ requestAnimationFrame(() => {
1510
+ postResumePersistPendingRef.current = false;
1511
+ persistNowRef.current();
1512
+ });
1513
+ });
1210
1514
  },
1211
1515
  []
1212
1516
  );
1213
- const applyPendingChildResume = useCallback3(() => {
1517
+ const applyPendingChildResumeRef = useRef7(() => {
1518
+ });
1519
+ const applyPendingChildResume = useCallback4(() => {
1214
1520
  const pending = pendingChildResumeRef.current;
1215
1521
  if (!pending || !ctx) return;
1216
1522
  const handles = ctx.getHandles();
@@ -1219,30 +1525,57 @@ function useCompoundPersistence(opts) {
1219
1525
  alreadyResumed: resumedChildKeysRef.current
1220
1526
  });
1221
1527
  if (!applied) {
1222
- if (handles.size === 0) {
1223
- const registeredOnly2 = stripOrphanChildStates(handles, pending.childStates);
1224
- resumeChildHandles(handles, registeredOnly2, {
1225
- alreadyResumed: resumedChildKeysRef.current
1226
- });
1227
- finalizeHydration(registeredOnly2);
1528
+ const registerable = registerablePendingKeys(pending.childStates);
1529
+ const missing = registerable.filter((k) => !handles.has(k));
1530
+ if (missing.length > 0 && hydrationRetryRef.current < MAX_HYDRATION_RETRIES) {
1531
+ hydrationRetryRef.current += 1;
1532
+ requestAnimationFrame(() => applyPendingChildResumeRef.current());
1228
1533
  return;
1229
1534
  }
1230
- const handlesAtWait = handles.size;
1231
- queueMicrotask(() => {
1232
- if (pendingChildResumeRef.current !== pending) return;
1233
- const handlesNow = ctx.getHandles();
1234
- if (handlesNow.size !== handlesAtWait) return;
1235
- const registeredOnly2 = stripOrphanChildStates(handlesNow, pending.childStates);
1236
- resumeChildHandles(handlesNow, registeredOnly2, {
1237
- alreadyResumed: resumedChildKeysRef.current
1238
- });
1239
- finalizeHydration(registeredOnly2);
1535
+ if (missing.length > 0 && isDevEnvironment()) {
1536
+ console.warn(
1537
+ `[lessonkit] Compound hydration: ${missing.length} child state(s) not restored (missing handles: ${missing.join(", ")})`
1538
+ );
1539
+ }
1540
+ lessonkitCtx?.config?.observability?.onCompoundHydrationPartial?.({
1541
+ compoundId: opts.compoundId,
1542
+ missingCheckIds: missing
1543
+ });
1544
+ for (const key of missing) {
1545
+ const state = pending.childStates[key];
1546
+ if (state) {
1547
+ loadedChildStatesRef.current[key] = state;
1548
+ }
1549
+ }
1550
+ const registeredOnly2 = stripOrphanChildStates(handles, pending.childStates);
1551
+ resumeChildHandles(handles, registeredOnly2, {
1552
+ alreadyResumed: resumedChildKeysRef.current
1553
+ });
1554
+ finalizeHydration({
1555
+ ...loadedChildStatesRef.current,
1556
+ ...registeredOnly2
1240
1557
  });
1241
1558
  return;
1242
1559
  }
1243
1560
  const registeredOnly = stripOrphanChildStates(handles, pending.childStates);
1244
1561
  finalizeHydration(registeredOnly);
1245
1562
  }, [ctx, finalizeHydration]);
1563
+ applyPendingChildResumeRef.current = applyPendingChildResume;
1564
+ useLayoutEffect(() => {
1565
+ if (!opts.enabled || !opts.courseId) return;
1566
+ markCompoundHydrated(compoundHydrationKey(opts.courseId, opts.compoundId));
1567
+ const saved = loadCompoundState2(storage, opts.courseId, opts.compoundId);
1568
+ if (!saved) return;
1569
+ if (Object.keys(saved.childStates).length > 0) {
1570
+ loadedChildStatesRef.current = { ...saved.childStates };
1571
+ skipSaveUntilHydratedRef.current = true;
1572
+ pendingChildResumeRef.current = saved;
1573
+ }
1574
+ const clamped = clampCompoundPageIndex(saved.activePageIndex, opts.pageCount);
1575
+ onCompoundResumeRef.current?.({ ...saved, activePageIndex: clamped });
1576
+ opts.setIndex(clamped);
1577
+ queueMicrotask(() => applyPendingChildResumeRef.current());
1578
+ }, [hydrationKey, opts.courseId, opts.compoundId, opts.enabled, opts.pageCount, storage]);
1246
1579
  const saveResume = useCompoundResume({
1247
1580
  courseId: opts.courseId,
1248
1581
  compoundId: opts.compoundId,
@@ -1252,29 +1585,65 @@ function useCompoundPersistence(opts) {
1252
1585
  const clamped = clampCompoundPageIndex(state.activePageIndex, opts.pageCount);
1253
1586
  loadedChildStatesRef.current = { ...state.childStates };
1254
1587
  skipSaveUntilHydratedRef.current = Object.keys(state.childStates).length > 0;
1588
+ onCompoundResumeRef.current?.({ ...state, activePageIndex: clamped });
1255
1589
  opts.setIndex(clamped);
1256
1590
  resumedChildKeysRef.current = /* @__PURE__ */ new Set();
1591
+ hydrationRetryRef.current = 0;
1257
1592
  pendingChildResumeRef.current = { ...state, activePageIndex: clamped, childStates: state.childStates };
1258
1593
  queueMicrotask(() => applyPendingChildResume());
1259
1594
  }
1260
1595
  });
1261
- const persistNow = useCallback3(() => {
1262
- if (!opts.enabled || !opts.courseId) return;
1263
- if (skipSaveUntilHydratedRef.current) return;
1264
- const built = buildStateRef.current();
1265
- const state = transformStateRef.current ? transformStateRef.current(built) : built;
1266
- saveResume(state);
1267
- }, [opts.enabled, opts.courseId, saveResume]);
1596
+ const buildBestEffortState = useCallback4(() => {
1597
+ const childStates = {
1598
+ ...loadedChildStatesRef.current
1599
+ };
1600
+ if (ctx) {
1601
+ for (const [checkId, entry] of ctx.getRegisteredHandles()) {
1602
+ if (opts.shouldIncludeChildState && !opts.shouldIncludeChildState(checkId, entry.pageIndex)) {
1603
+ continue;
1604
+ }
1605
+ const handle = entry.handle;
1606
+ if (handle.getCurrentState) {
1607
+ const live = handle.getCurrentState();
1608
+ if (!isEmptyResumeState(live)) {
1609
+ childStates[checkId] = live;
1610
+ }
1611
+ }
1612
+ }
1613
+ }
1614
+ const built = createCompoundResumeState3({
1615
+ activePageIndex: clampCompoundPageIndex(opts.index, opts.pageCount),
1616
+ childStates
1617
+ });
1618
+ return transformStateRef.current ? transformStateRef.current(built) : built;
1619
+ }, [ctx, opts.index, opts.pageCount, opts.shouldIncludeChildState]);
1620
+ const persistNow = useCallback4(
1621
+ (options) => {
1622
+ if (!opts.enabled || !opts.courseId) return;
1623
+ if (options?.forceDuringHydration) {
1624
+ saveResume(buildBestEffortState());
1625
+ return;
1626
+ }
1627
+ if (skipSaveUntilHydratedRef.current) return;
1628
+ if (postResumePersistPendingRef.current) return;
1629
+ const built = buildStateRef.current();
1630
+ const state = transformStateRef.current ? transformStateRef.current(built) : built;
1631
+ saveResume(state);
1632
+ },
1633
+ [opts.enabled, opts.courseId, saveResume, buildBestEffortState]
1634
+ );
1268
1635
  useEffect7(() => {
1269
1636
  persistNowRef.current = persistNow;
1270
1637
  }, [persistNow]);
1271
- const notifyImperativeResume = useCallback3(
1638
+ const notifyImperativeResume = useCallback4(
1272
1639
  (state) => {
1273
1640
  const clamped = clampCompoundPageIndex(state.activePageIndex, opts.pageCount);
1274
1641
  loadedChildStatesRef.current = { ...state.childStates };
1275
1642
  skipSaveUntilHydratedRef.current = Object.keys(state.childStates).length > 0;
1643
+ onCompoundResumeRef.current?.({ ...state, activePageIndex: clamped });
1276
1644
  opts.setIndex(clamped);
1277
1645
  resumedChildKeysRef.current = /* @__PURE__ */ new Set();
1646
+ hydrationRetryRef.current = 0;
1278
1647
  pendingChildResumeRef.current = { ...state, activePageIndex: clamped, childStates: state.childStates };
1279
1648
  queueMicrotask(() => applyPendingChildResume());
1280
1649
  },
@@ -1294,17 +1663,22 @@ function useCompoundPersistence(opts) {
1294
1663
  }, [opts.index, handlesVersion, applyPendingChildResume]);
1295
1664
  useEffect7(() => {
1296
1665
  persistNow();
1297
- }, [persistNow, opts.index, opts.pageCount, handlesVersion]);
1666
+ }, [persistNow, opts.index, opts.pageCount, handlesVersion, opts.persistTrigger]);
1298
1667
  useEffect7(() => {
1299
1668
  if (!opts.enabled || !opts.courseId || typeof document === "undefined") return;
1300
1669
  const flushOnExit = () => {
1301
- if (document.visibilityState === "hidden") persistNow();
1670
+ if (document.visibilityState === "hidden") {
1671
+ persistNow({ forceDuringHydration: skipSaveUntilHydratedRef.current });
1672
+ }
1302
1673
  };
1303
1674
  document.addEventListener("visibilitychange", flushOnExit);
1304
- window.addEventListener("pagehide", flushOnExit);
1675
+ const flushOnPageHide = () => {
1676
+ persistNow({ forceDuringHydration: skipSaveUntilHydratedRef.current });
1677
+ };
1678
+ window.addEventListener("pagehide", flushOnPageHide);
1305
1679
  return () => {
1306
1680
  document.removeEventListener("visibilitychange", flushOnExit);
1307
- window.removeEventListener("pagehide", flushOnExit);
1681
+ window.removeEventListener("pagehide", flushOnPageHide);
1308
1682
  };
1309
1683
  }, [opts.enabled, opts.courseId, persistNow]);
1310
1684
  }
@@ -1320,7 +1694,9 @@ function useCompoundShell(opts) {
1320
1694
  setIndex: opts.setIndex,
1321
1695
  enabled: opts.persistEnabled,
1322
1696
  storage: opts.storage,
1323
- transformState: opts.transformState
1697
+ transformState: opts.transformState,
1698
+ persistTrigger: opts.persistTrigger,
1699
+ onCompoundResume: opts.onCompoundResume
1324
1700
  });
1325
1701
  const { goNext, goPrev, progress } = useCompoundNavigation(opts.pageCount, opts.index, opts.setIndex);
1326
1702
  const visibleIndex = clampCompoundPageIndex2(opts.index, opts.pageCount);
@@ -1347,13 +1723,19 @@ function useCompoundInitialIndex(opts) {
1347
1723
  );
1348
1724
  }
1349
1725
 
1350
- // src/compound/warnPersistence.ts
1351
- var DEFAULT_ASSESSMENT_SEQUENCE_COMPOUND_ID = "assessment-sequence";
1352
- function warnSharedCompoundStorageKey(opts) {
1353
- if (!opts.persistEnabled || opts.hasExplicitBlockId || !isDevEnvironment()) return;
1354
- console.warn(
1355
- `[lessonkit] <${opts.componentName}> without blockId shares one sessionStorage key when persistCompoundState is enabled; set a unique blockId per instance.`
1356
- );
1726
+ // src/compound/requireCompoundBlockId.ts
1727
+ var MissingCompoundBlockIdError = class extends Error {
1728
+ constructor(componentName) {
1729
+ super(
1730
+ `[lessonkit] <${componentName}> requires a unique blockId when session.persistCompoundState is enabled`
1731
+ );
1732
+ this.name = "MissingCompoundBlockIdError";
1733
+ }
1734
+ };
1735
+ function requireCompoundBlockIdWhenPersisting(opts) {
1736
+ if (opts.persistEnabled && !opts.blockId) {
1737
+ throw new MissingCompoundBlockIdError(opts.componentName);
1738
+ }
1357
1739
  }
1358
1740
 
1359
1741
  // src/blocks/AssessmentSequence.tsx
@@ -1362,7 +1744,10 @@ var AssessmentSequenceInner = forwardRef6(
1362
1744
  function AssessmentSequenceInner2(props, ref) {
1363
1745
  const { compoundId, childArray, index, setIndex, persistEnabled } = props;
1364
1746
  const sequential = props.sequential !== false;
1747
+ const requireAnswerBeforeNext = props.requireAnswerBeforeNext !== false;
1365
1748
  const { config } = useLessonkit();
1749
+ const registry = useCompoundRegistry();
1750
+ const handlesVersion = useCompoundHandlesVersion();
1366
1751
  const { visibleIndex, goNext, goPrev, progress } = useCompoundShell({
1367
1752
  courseId: config.courseId,
1368
1753
  compoundId,
@@ -1374,6 +1759,21 @@ var AssessmentSequenceInner = forwardRef6(
1374
1759
  enableSolutionsButton: props.enableSolutionsButton
1375
1760
  });
1376
1761
  validateCompoundChildren("AssessmentSequence", props.children);
1762
+ const activeStepAnswered = useMemo7(() => {
1763
+ if (!requireAnswerBeforeNext || !registry) return true;
1764
+ let handlesForStep = 0;
1765
+ for (const entry of registry.getRegisteredHandles().values()) {
1766
+ if (entry.pageIndex !== visibleIndex) continue;
1767
+ handlesForStep += 1;
1768
+ if (!entry.handle.getAnswerGiven()) return false;
1769
+ }
1770
+ if (handlesForStep === 0) {
1771
+ const child = childArray[visibleIndex];
1772
+ const childProps = child?.props;
1773
+ if (child && typeof childProps?.checkId === "string") return false;
1774
+ }
1775
+ return true;
1776
+ }, [childArray, handlesVersion, registry, requireAnswerBeforeNext, visibleIndex]);
1377
1777
  if (!sequential) {
1378
1778
  return /* @__PURE__ */ jsx6("section", { "aria-label": "Assessment sequence", "data-testid": "assessment-sequence", children: props.children });
1379
1779
  }
@@ -1401,7 +1801,7 @@ var AssessmentSequenceInner = forwardRef6(
1401
1801
  {
1402
1802
  type: "button",
1403
1803
  "data-testid": "sequence-next",
1404
- disabled: visibleIndex >= childArray.length - 1 || childArray.length === 0,
1804
+ disabled: visibleIndex >= childArray.length - 1 || childArray.length === 0 || !activeStepAnswered,
1405
1805
  onClick: goNext,
1406
1806
  children: "Next"
1407
1807
  }
@@ -1417,22 +1817,22 @@ var AssessmentSequence = forwardRef6(
1417
1817
  if (!props.blockId && !autoCompoundIdRef.current) {
1418
1818
  autoCompoundIdRef.current = deriveId(`assessment-sequence-${reactInstanceId}`);
1419
1819
  }
1420
- const compoundId = useMemo7(
1421
- () => props.blockId ? normalizeComponentId(props.blockId, "blockId") : autoCompoundIdRef.current ?? DEFAULT_ASSESSMENT_SEQUENCE_COMPOUND_ID,
1422
- [props.blockId]
1423
- );
1820
+ const compoundId = useMemo7(() => {
1821
+ if (props.blockId) {
1822
+ return normalizeComponentId(props.blockId, "blockId");
1823
+ }
1824
+ return autoCompoundIdRef.current ?? deriveId(`assessment-sequence-${reactInstanceId}`);
1825
+ }, [props.blockId, reactInstanceId]);
1424
1826
  const childArray = React7.Children.toArray(props.children).filter(
1425
1827
  React7.isValidElement
1426
1828
  );
1427
1829
  const { config, storage } = useLessonkit();
1428
1830
  const persistEnabled = config.session?.persistCompoundState !== false;
1429
- useEffect8(() => {
1430
- warnSharedCompoundStorageKey({
1431
- persistEnabled,
1432
- hasExplicitBlockId: Boolean(props.blockId),
1433
- componentName: "AssessmentSequence"
1434
- });
1435
- }, [persistEnabled, props.blockId]);
1831
+ requireCompoundBlockIdWhenPersisting({
1832
+ persistEnabled,
1833
+ blockId: props.blockId,
1834
+ componentName: "AssessmentSequence"
1835
+ });
1436
1836
  const initialIndex = useCompoundInitialIndex({
1437
1837
  courseId: config.courseId,
1438
1838
  compoundId,
@@ -1441,7 +1841,7 @@ var AssessmentSequence = forwardRef6(
1441
1841
  storage
1442
1842
  });
1443
1843
  const [index, setIndex] = useState6(initialIndex);
1444
- const setIndexStable = useCallback4((i) => setIndex(i), []);
1844
+ const setIndexStable = useCallback5((i) => setIndex(i), []);
1445
1845
  useEffect8(() => {
1446
1846
  setIndex(initialIndex);
1447
1847
  }, [config.courseId, compoundId, initialIndex]);
@@ -1477,14 +1877,180 @@ function Heading(props) {
1477
1877
  }
1478
1878
  setLessonkitBlockType(Heading, "Heading");
1479
1879
 
1880
+ // src/blocks/embedSecurity.ts
1881
+ var BLOCKED_SANDBOX_TOKENS = /* @__PURE__ */ new Set([
1882
+ "allow-top-navigation",
1883
+ "allow-top-navigation-by-user-activation",
1884
+ "allow-modals",
1885
+ "allow-downloads",
1886
+ "allow-popups-to-escape-sandbox"
1887
+ ]);
1888
+ var ALLOWED_SANDBOX_TOKENS = /* @__PURE__ */ new Set([
1889
+ "allow-forms",
1890
+ "allow-popups",
1891
+ "allow-presentation"
1892
+ ]);
1893
+ var DEFAULT_SANDBOX = "allow-scripts";
1894
+ function isProductionEmbedBuild() {
1895
+ try {
1896
+ if (import.meta.env?.PROD === true) return true;
1897
+ } catch {
1898
+ }
1899
+ const g = globalThis;
1900
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
1901
+ }
1902
+ function allowedEmbedSchemes() {
1903
+ return isProductionEmbedBuild() ? /* @__PURE__ */ new Set(["https:"]) : /* @__PURE__ */ new Set(["https:", "http:"]);
1904
+ }
1905
+ function normalizeHostname(hostname) {
1906
+ return hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
1907
+ }
1908
+ function expandIpv4Literal(hostname) {
1909
+ if (/^\d+$/.test(hostname)) {
1910
+ const value2 = Number(hostname);
1911
+ if (!Number.isInteger(value2) || value2 < 0 || value2 > 4294967295) return null;
1912
+ return `${value2 >>> 24 & 255}.${value2 >>> 16 & 255}.${value2 >>> 8 & 255}.${value2 & 255}`;
1913
+ }
1914
+ if (!/^\d+(?:\.\d+){1,3}$/.test(hostname)) return null;
1915
+ const parts = hostname.split(".").map((part) => Number(part));
1916
+ if (parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
1917
+ let value = 0;
1918
+ for (const part of parts) {
1919
+ value = value << 8 | part;
1920
+ }
1921
+ value <<= (4 - parts.length) * 8;
1922
+ return `${value >>> 24 & 255}.${value >>> 16 & 255}.${value >>> 8 & 255}.${value & 255}`;
1923
+ }
1924
+ function canonicalHostnameForBlocklist(hostname) {
1925
+ const normalized = normalizeHostname(hostname);
1926
+ return expandIpv4Literal(normalized) ?? normalized;
1927
+ }
1928
+ function isIpv4MappedAddress(hostname) {
1929
+ const match = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
1930
+ return match?.[1] ?? null;
1931
+ }
1932
+ function isLoopbackHost(hostname) {
1933
+ const ipv4Mapped = isIpv4MappedAddress(hostname);
1934
+ if (ipv4Mapped) return isLoopbackHost(ipv4Mapped);
1935
+ 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";
1936
+ }
1937
+ function isIpv6UniqueLocalHost(hostname) {
1938
+ return /^f[cd][0-9a-f]{0,2}:/i.test(hostname);
1939
+ }
1940
+ function isLinkLocalOrMetadataHost(hostname) {
1941
+ if (hostname === "169.254.169.254") return true;
1942
+ if (/^169\.254\./.test(hostname)) return true;
1943
+ if (/^fe80:/i.test(hostname)) return true;
1944
+ return false;
1945
+ }
1946
+ function isRfc1918Host(hostname) {
1947
+ const ipv4Mapped = isIpv4MappedAddress(hostname);
1948
+ if (ipv4Mapped) return isRfc1918Host(ipv4Mapped);
1949
+ if (/^10\./.test(hostname)) return true;
1950
+ if (/^192\.168\./.test(hostname)) return true;
1951
+ const parts = hostname.split(".").map(Number);
1952
+ if (parts.length === 4 && parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
1953
+ return false;
1954
+ }
1955
+ function isBlockedHost(hostname, allowedHosts) {
1956
+ const normalized = normalizeHostname(hostname);
1957
+ const canonical = canonicalHostnameForBlocklist(hostname);
1958
+ if (allowedHosts?.some((host) => {
1959
+ const allowedNormalized = normalizeHostname(host);
1960
+ return canonicalHostnameForBlocklist(host) === canonical || allowedNormalized === normalized;
1961
+ })) {
1962
+ return false;
1963
+ }
1964
+ if (!isProductionEmbedBuild()) return false;
1965
+ return isLoopbackHost(canonical) || isLinkLocalOrMetadataHost(canonical) || isRfc1918Host(canonical) || isIpv6UniqueLocalHost(normalized);
1966
+ }
1967
+ function resolveAllowedUrl(src, options) {
1968
+ const trimmed = src.trim();
1969
+ if (!trimmed) return null;
1970
+ try {
1971
+ const base = typeof window !== "undefined" ? window.location.href : "https://example.com/";
1972
+ const url = new URL(trimmed, base);
1973
+ if (!allowedEmbedSchemes().has(url.protocol)) return null;
1974
+ if (isBlockedHost(url.hostname, options?.allowedHosts)) return null;
1975
+ if (typeof window !== "undefined") {
1976
+ const pageOrigin = window.location.origin;
1977
+ const isAbsolute = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed) || trimmed.startsWith("//");
1978
+ if (!isAbsolute && url.origin !== pageOrigin) return null;
1979
+ if (trimmed.startsWith("//") && url.origin !== pageOrigin) return null;
1980
+ }
1981
+ url.username = "";
1982
+ url.password = "";
1983
+ return url.href;
1984
+ } catch {
1985
+ return null;
1986
+ }
1987
+ }
1988
+ function resolveEmbedSrc(src, options) {
1989
+ return resolveAllowedUrl(src, options);
1990
+ }
1991
+ function resolveMediaSrc(src, options) {
1992
+ if (src === void 0) return null;
1993
+ return resolveAllowedUrl(src, options);
1994
+ }
1995
+ function buildEmbedSandbox(allow, options) {
1996
+ const tokens = /* @__PURE__ */ new Set([DEFAULT_SANDBOX]);
1997
+ if (allow) {
1998
+ for (const raw of allow.split(/\s+/)) {
1999
+ const token = raw.trim();
2000
+ if (!token || BLOCKED_SANDBOX_TOKENS.has(token)) continue;
2001
+ if (ALLOWED_SANDBOX_TOKENS.has(token)) tokens.add(token);
2002
+ }
2003
+ }
2004
+ if (options?.restrictPopupsInProduction !== false && isProductionEmbedBuild()) {
2005
+ tokens.delete("allow-popups");
2006
+ }
2007
+ return [...tokens].join(" ");
2008
+ }
2009
+ function telemetryEmbedSrc(src) {
2010
+ try {
2011
+ const url = new URL(src);
2012
+ url.username = "";
2013
+ url.password = "";
2014
+ url.search = "";
2015
+ url.hash = "";
2016
+ return `${url.origin}${url.pathname}`;
2017
+ } catch {
2018
+ return src;
2019
+ }
2020
+ }
2021
+ function resolveEmbedAspectRatio(aspectRatio) {
2022
+ if (!aspectRatio) return void 0;
2023
+ const trimmed = aspectRatio.trim();
2024
+ if (!/^\d+(\.\d+)?\s*\/\s*\d+(\.\d+)?$/.test(trimmed)) return void 0;
2025
+ const [numRaw, denRaw] = trimmed.split("/").map((part) => part.trim());
2026
+ const num = Number(numRaw);
2027
+ const den = Number(denRaw);
2028
+ if (!Number.isFinite(num) || !Number.isFinite(den) || num <= 0 || den <= 0) return void 0;
2029
+ return trimmed;
2030
+ }
2031
+
1480
2032
  // src/blocks/Image.tsx
1481
2033
  import { jsx as jsx9 } from "react/jsx-runtime";
1482
2034
  function Image(props) {
1483
- return /* @__PURE__ */ jsx9(
1484
- "img",
1485
- {
1486
- src: props.src,
1487
- alt: props.alt,
2035
+ const { config } = useLessonkit();
2036
+ const resolvedSrc = resolveMediaSrc(props.src, {
2037
+ allowedHosts: config.embed?.allowedHosts
2038
+ });
2039
+ if (!resolvedSrc) {
2040
+ return /* @__PURE__ */ jsx9(
2041
+ "figure",
2042
+ {
2043
+ "data-lk-block-id": props.blockId,
2044
+ "data-testid": props.blockId ? `image-${props.blockId}` : "image",
2045
+ children: /* @__PURE__ */ jsx9("p", { role: "alert", children: "This image URL is not allowed." })
2046
+ }
2047
+ );
2048
+ }
2049
+ return /* @__PURE__ */ jsx9(
2050
+ "img",
2051
+ {
2052
+ src: resolvedSrc,
2053
+ alt: props.alt,
1488
2054
  "data-lk-block-id": props.blockId,
1489
2055
  "data-testid": props.blockId ? `image-${props.blockId}` : "image",
1490
2056
  style: { maxWidth: "100%", height: "auto" }
@@ -1497,10 +2063,21 @@ setLessonkitBlockType(Image, "Image");
1497
2063
  import { useMemo as useMemo8 } from "react";
1498
2064
  import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
1499
2065
  function Video(props) {
2066
+ const { config } = useLessonkit();
1500
2067
  const blockId = useMemo8(
1501
2068
  () => normalizeComponentId(props.blockId, "blockId"),
1502
2069
  [props.blockId]
1503
2070
  );
2071
+ const mediaOptions = { allowedHosts: config.embed?.allowedHosts };
2072
+ const resolvedSrc = resolveMediaSrc(props.src, mediaOptions);
2073
+ const resolvedPoster = resolveMediaSrc(props.poster, mediaOptions);
2074
+ const resolvedCaptions = resolveMediaSrc(props.captions, mediaOptions);
2075
+ if (!resolvedSrc) {
2076
+ return /* @__PURE__ */ jsxs7("section", { "aria-label": props.title ?? "Video", "data-lk-block-id": blockId, "data-testid": "video", children: [
2077
+ props.title ? /* @__PURE__ */ jsx10("h3", { "data-testid": "video-title", children: props.title }) : null,
2078
+ /* @__PURE__ */ jsx10("p", { role: "alert", "data-testid": "video-blocked", children: "This video URL is not allowed." })
2079
+ ] });
2080
+ }
1504
2081
  return /* @__PURE__ */ jsxs7("section", { "aria-label": props.title ?? "Video", "data-lk-block-id": blockId, "data-testid": "video", children: [
1505
2082
  props.title ? /* @__PURE__ */ jsx10("h3", { "data-testid": "video-title", children: props.title }) : null,
1506
2083
  /* @__PURE__ */ jsx10(
@@ -1508,11 +2085,20 @@ function Video(props) {
1508
2085
  {
1509
2086
  controls: true,
1510
2087
  preload: "metadata",
1511
- poster: props.poster,
1512
- src: props.src,
2088
+ poster: resolvedPoster ?? void 0,
2089
+ src: resolvedSrc,
1513
2090
  "data-testid": "video-player",
1514
2091
  style: { maxWidth: "100%" },
1515
- children: props.captions ? /* @__PURE__ */ jsx10("track", { kind: "captions", src: props.captions, srcLang: "en", label: "Captions", default: true }) : null
2092
+ children: resolvedCaptions ? /* @__PURE__ */ jsx10(
2093
+ "track",
2094
+ {
2095
+ kind: "captions",
2096
+ src: resolvedCaptions,
2097
+ srcLang: "en",
2098
+ label: "Captions",
2099
+ default: true
2100
+ }
2101
+ ) : null
1516
2102
  }
1517
2103
  )
1518
2104
  ] });
@@ -1555,7 +2141,7 @@ function Page(props) {
1555
2141
  setLessonkitBlockType(Page, "Page");
1556
2142
 
1557
2143
  // src/blocks/InteractiveBook.tsx
1558
- import React11, { forwardRef as forwardRef7, useCallback as useCallback5, useEffect as useEffect10, useMemo as useMemo9, useState as useState7 } from "react";
2144
+ import React11, { forwardRef as forwardRef7, useCallback as useCallback6, useEffect as useEffect10, useMemo as useMemo9, useState as useState7 } from "react";
1559
2145
  import { jsx as jsx12, jsxs as jsxs9 } from "react/jsx-runtime";
1560
2146
  var InteractiveBookInner = forwardRef7(
1561
2147
  function InteractiveBookInner2(props, ref) {
@@ -1637,15 +2223,20 @@ var InteractiveBookInner = forwardRef7(
1637
2223
  }
1638
2224
  );
1639
2225
  var InteractiveBook = forwardRef7(function InteractiveBook2(props, ref) {
1640
- const blockId = useMemo9(
1641
- () => normalizeComponentId(props.blockId, "blockId"),
1642
- [props.blockId]
1643
- );
1644
2226
  const pages = React11.Children.toArray(props.children).filter(
1645
2227
  React11.isValidElement
1646
2228
  );
1647
2229
  const { config, storage } = useLessonkit();
1648
2230
  const persistEnabled = config.session?.persistCompoundState !== false;
2231
+ requireCompoundBlockIdWhenPersisting({
2232
+ persistEnabled,
2233
+ blockId: props.blockId,
2234
+ componentName: "InteractiveBook"
2235
+ });
2236
+ const blockId = useMemo9(
2237
+ () => normalizeComponentId(props.blockId, "blockId"),
2238
+ [props.blockId]
2239
+ );
1649
2240
  const initialIndex = useCompoundInitialIndex({
1650
2241
  courseId: config.courseId,
1651
2242
  compoundId: blockId,
@@ -1654,7 +2245,7 @@ var InteractiveBook = forwardRef7(function InteractiveBook2(props, ref) {
1654
2245
  storage
1655
2246
  });
1656
2247
  const [index, setIndex] = useState7(initialIndex);
1657
- const setIndexStable = useCallback5((i) => setIndex(i), []);
2248
+ const setIndexStable = useCallback6((i) => setIndex(i), []);
1658
2249
  useEffect10(() => {
1659
2250
  setIndex(initialIndex);
1660
2251
  }, [config.courseId, blockId, initialIndex]);
@@ -1709,7 +2300,7 @@ function Slide(props) {
1709
2300
  setLessonkitBlockType(Slide, "Slide");
1710
2301
 
1711
2302
  // src/blocks/SlideDeck.tsx
1712
- import React13, { forwardRef as forwardRef8, useCallback as useCallback6, useEffect as useEffect13, useMemo as useMemo10, useRef as useRef9, useState as useState8 } from "react";
2303
+ import React13, { forwardRef as forwardRef8, useCallback as useCallback7, useEffect as useEffect13, useMemo as useMemo10, useRef as useRef9, useState as useState8 } from "react";
1713
2304
 
1714
2305
  // src/compound/useCompoundKeyboardNav.ts
1715
2306
  import { useEffect as useEffect12 } from "react";
@@ -1786,7 +2377,7 @@ var SlideDeckInner = forwardRef8(function SlideDeckInner2(props, ref) {
1786
2377
  persistEnabled,
1787
2378
  ref
1788
2379
  });
1789
- const setIndexStable = useCallback6((i) => setIndex(i), [setIndex]);
2380
+ const setIndexStable = useCallback7((i) => setIndex(i), [setIndex]);
1790
2381
  useCompoundKeyboardNav({
1791
2382
  containerRef,
1792
2383
  visibleIndex,
@@ -1869,15 +2460,20 @@ var SlideDeckInner = forwardRef8(function SlideDeckInner2(props, ref) {
1869
2460
  );
1870
2461
  });
1871
2462
  var SlideDeck = forwardRef8(function SlideDeck2(props, ref) {
1872
- const blockId = useMemo10(
1873
- () => normalizeComponentId(props.blockId, "blockId"),
1874
- [props.blockId]
1875
- );
1876
2463
  const slides = React13.Children.toArray(props.children).filter(
1877
2464
  React13.isValidElement
1878
2465
  );
1879
2466
  const { config, storage } = useLessonkit();
1880
2467
  const persistEnabled = config.session?.persistCompoundState !== false;
2468
+ requireCompoundBlockIdWhenPersisting({
2469
+ persistEnabled,
2470
+ blockId: props.blockId,
2471
+ componentName: "SlideDeck"
2472
+ });
2473
+ const blockId = useMemo10(
2474
+ () => normalizeComponentId(props.blockId, "blockId"),
2475
+ [props.blockId]
2476
+ );
1881
2477
  const initialIndex = useCompoundInitialIndex({
1882
2478
  courseId: config.courseId,
1883
2479
  compoundId: blockId,
@@ -1886,7 +2482,7 @@ var SlideDeck = forwardRef8(function SlideDeck2(props, ref) {
1886
2482
  storage
1887
2483
  });
1888
2484
  const [index, setIndex] = useState8(initialIndex);
1889
- const setIndexStable = useCallback6((i) => setIndex(i), []);
2485
+ const setIndexStable = useCallback7((i) => setIndex(i), []);
1890
2486
  useEffect13(() => {
1891
2487
  setIndex(initialIndex);
1892
2488
  }, [config.courseId, blockId, initialIndex]);
@@ -1953,37 +2549,19 @@ function TimedCue(props) {
1953
2549
  setLessonkitBlockType(TimedCue, "TimedCue");
1954
2550
 
1955
2551
  // src/blocks/InteractiveVideo.tsx
1956
- import React15, { forwardRef as forwardRef9, useCallback as useCallback7, useEffect as useEffect15, useMemo as useMemo11, useRef as useRef11, useState as useState9 } from "react";
2552
+ import React15, { forwardRef as forwardRef9, useCallback as useCallback8, useEffect as useEffect15, useMemo as useMemo11, useRef as useRef11, useState as useState9 } from "react";
1957
2553
  import { loadCompoundState as loadCompoundState3 } from "@lessonkit/core";
1958
-
1959
- // src/compound/useCompoundVideoShell.ts
1960
- import "@lessonkit/core";
1961
- var IV_META_KEY = "__lk_iv__";
1962
- function readInteractiveVideoMeta(childStates) {
1963
- const raw = childStates[IV_META_KEY];
1964
- if (!raw || typeof raw !== "object") return null;
1965
- const currentTime = typeof raw.currentTime === "number" ? raw.currentTime : 0;
1966
- const completedCueIndices = Array.isArray(raw.completedCueIndices) ? raw.completedCueIndices.filter((n) => typeof n === "number") : [];
1967
- return { currentTime, completedCueIndices };
1968
- }
1969
- function mergeVideoMetaIntoState(state, meta) {
1970
- return {
1971
- ...state,
1972
- childStates: {
1973
- ...state.childStates,
1974
- [IV_META_KEY]: meta
1975
- }
1976
- };
1977
- }
1978
-
1979
- // src/blocks/InteractiveVideo.tsx
1980
2554
  import { Fragment, jsx as jsx16, jsxs as jsxs13 } from "react/jsx-runtime";
2555
+ function sortCuesByTime(cues) {
2556
+ return [...cues].sort((a, b) => (a.props.atSeconds ?? 0) - (b.props.atSeconds ?? 0));
2557
+ }
1981
2558
  function loadVideoMeta(storage, courseId, blockId, enabled) {
1982
- if (!enabled || !courseId) return { currentTime: 0, completedCueIndices: [] };
2559
+ const empty = { currentTime: 0, completedCueIndices: [], firedCueIndices: [] };
2560
+ if (!enabled || !courseId) return empty;
1983
2561
  const saved = loadCompoundState3(storage, courseId, blockId);
1984
- if (!saved) return { currentTime: 0, completedCueIndices: [] };
2562
+ if (!saved) return empty;
1985
2563
  const meta = readInteractiveVideoMeta(saved.childStates);
1986
- return meta ?? { currentTime: 0, completedCueIndices: [] };
2564
+ return meta ?? empty;
1987
2565
  }
1988
2566
  function getCueChildCheckId(cue) {
1989
2567
  const child = React15.Children.only(cue.props.children);
@@ -2000,29 +2578,59 @@ var InteractiveVideoInner = forwardRef9(function InteractiveVideoInner2(props, r
2000
2578
  validateCompoundChildren("InteractiveVideo", cues);
2001
2579
  const { config, track, storage } = useLessonkit();
2002
2580
  const lessonId = useEnclosingLessonId();
2581
+ const mediaOptions = { allowedHosts: config.embed?.allowedHosts };
2582
+ const resolvedSrc = resolveMediaSrc(props.src, mediaOptions);
2583
+ const resolvedPoster = resolveMediaSrc(props.poster, mediaOptions);
2584
+ const resolvedCaptions = resolveMediaSrc(props.captions, mediaOptions);
2003
2585
  const videoRef = useRef11(null);
2586
+ const lastKnownTimeRef = useRef11(initialMeta.currentTime);
2004
2587
  const completedCuesRef = useRef11(new Set(initialMeta.completedCueIndices));
2005
2588
  const [completedCues, setCompletedCues] = useState9(
2006
2589
  () => new Set(initialMeta.completedCueIndices)
2007
2590
  );
2008
2591
  const [overlayActive, setOverlayActive] = useState9(false);
2009
- const firedCuesRef = useRef11(new Set(initialMeta.completedCueIndices));
2010
- const resumeOverlayCheckedRef = useRef11(false);
2011
- const sortedCues = useMemo11(
2012
- () => [...cues].sort((a, b) => (a.props.atSeconds ?? 0) - (b.props.atSeconds ?? 0)),
2013
- [cues]
2592
+ const firedCuesRef = useRef11(
2593
+ new Set(
2594
+ initialMeta.firedCueIndices.length > 0 ? initialMeta.firedCueIndices : initialMeta.completedCueIndices
2595
+ )
2014
2596
  );
2597
+ const resumeOverlayCheckedRef = useRef11(false);
2598
+ const [persistTrigger, setPersistTrigger] = useState9(0);
2599
+ const lastPersistTimeRef = useRef11(0);
2600
+ const sortedCues = cues;
2015
2601
  useEffect15(() => {
2016
2602
  completedCuesRef.current = completedCues;
2017
2603
  }, [completedCues]);
2018
- const transformState = useCallback7(
2019
- (state) => mergeVideoMetaIntoState(state, {
2020
- currentTime: videoRef.current?.currentTime ?? initialMeta.currentTime,
2021
- completedCueIndices: [...completedCuesRef.current]
2022
- }),
2023
- [initialMeta.currentTime]
2604
+ const transformState = useCallback8(
2605
+ (state) => {
2606
+ const liveTime = videoRef.current?.currentTime;
2607
+ const currentTime = Math.max(
2608
+ lastKnownTimeRef.current,
2609
+ typeof liveTime === "number" && Number.isFinite(liveTime) ? liveTime : 0
2610
+ );
2611
+ return mergeVideoMetaIntoState(state, {
2612
+ currentTime,
2613
+ completedCueIndices: [...completedCuesRef.current],
2614
+ firedCueIndices: [...firedCuesRef.current]
2615
+ });
2616
+ },
2617
+ []
2024
2618
  );
2025
- const { ctx } = useCompoundShell({
2619
+ const applyVideoMetaFromState = useCallback8((state) => {
2620
+ const meta = readInteractiveVideoMeta(state.childStates);
2621
+ if (!meta) return;
2622
+ lastKnownTimeRef.current = meta.currentTime;
2623
+ completedCuesRef.current = new Set(meta.completedCueIndices);
2624
+ firedCuesRef.current = new Set(
2625
+ meta.firedCueIndices.length > 0 ? meta.firedCueIndices : meta.completedCueIndices
2626
+ );
2627
+ setCompletedCues(new Set(meta.completedCueIndices));
2628
+ const video = videoRef.current;
2629
+ if (video && meta.currentTime > 0) {
2630
+ video.currentTime = meta.currentTime;
2631
+ }
2632
+ }, []);
2633
+ const { visibleIndex, ctx } = useCompoundShell({
2026
2634
  courseId: config.courseId,
2027
2635
  compoundId: blockId,
2028
2636
  pageCount: sortedCues.length,
@@ -2031,10 +2639,12 @@ var InteractiveVideoInner = forwardRef9(function InteractiveVideoInner2(props, r
2031
2639
  persistEnabled,
2032
2640
  ref,
2033
2641
  storage,
2034
- transformState
2642
+ transformState,
2643
+ persistTrigger,
2644
+ onCompoundResume: applyVideoMetaFromState
2035
2645
  });
2036
- const activeCue = sortedCues[index];
2037
- const cueCanContinue = useCallback7(
2646
+ const activeCue = sortedCues[visibleIndex];
2647
+ const cueCanContinue = useCallback8(
2038
2648
  (cue) => {
2039
2649
  if (!cue || !cueRequiresAnswer(cue)) return true;
2040
2650
  const checkId = getCueChildCheckId(cue);
@@ -2058,8 +2668,8 @@ var InteractiveVideoInner = forwardRef9(function InteractiveVideoInner2(props, r
2058
2668
  if (!hasSavedProgress) return;
2059
2669
  const video = videoRef.current;
2060
2670
  if (!video) return;
2061
- const cue = sortedCues[index];
2062
- if (!cue || completedCues.has(index)) return;
2671
+ const cue = sortedCues[visibleIndex];
2672
+ if (!cue || completedCues.has(visibleIndex)) return;
2063
2673
  setOverlayActive(true);
2064
2674
  video.pause();
2065
2675
  const at = cue.props.atSeconds ?? 0;
@@ -2071,13 +2681,14 @@ var InteractiveVideoInner = forwardRef9(function InteractiveVideoInner2(props, r
2071
2681
  completedCues,
2072
2682
  config.courseId,
2073
2683
  index,
2684
+ visibleIndex,
2074
2685
  initialMeta.completedCueIndices.length,
2075
2686
  initialMeta.currentTime,
2076
2687
  persistEnabled,
2077
2688
  sortedCues,
2078
2689
  storage
2079
2690
  ]);
2080
- const mandatoryIncompleteBefore = useCallback7(
2691
+ const mandatoryIncompleteBefore = useCallback8(
2081
2692
  (time) => {
2082
2693
  for (let i = 0; i < sortedCues.length; i++) {
2083
2694
  const cue = sortedCues[i];
@@ -2088,7 +2699,7 @@ var InteractiveVideoInner = forwardRef9(function InteractiveVideoInner2(props, r
2088
2699
  },
2089
2700
  [sortedCues, completedCues]
2090
2701
  );
2091
- const activateCue = useCallback7(
2702
+ const activateCue = useCallback8(
2092
2703
  (i) => {
2093
2704
  const cue = sortedCues[i];
2094
2705
  if (!cue || firedCuesRef.current.has(i)) return;
@@ -2110,6 +2721,12 @@ var InteractiveVideoInner = forwardRef9(function InteractiveVideoInner2(props, r
2110
2721
  const video = videoRef.current;
2111
2722
  if (!video || overlayActive) return;
2112
2723
  const t = video.currentTime;
2724
+ lastKnownTimeRef.current = Math.max(lastKnownTimeRef.current, t);
2725
+ const now = Date.now();
2726
+ if (now - lastPersistTimeRef.current >= 5e3) {
2727
+ lastPersistTimeRef.current = now;
2728
+ setPersistTrigger((n) => n + 1);
2729
+ }
2113
2730
  const blockSeek = mandatoryIncompleteBefore(t);
2114
2731
  if (blockSeek !== null && t > blockSeek + 0.5) {
2115
2732
  video.currentTime = blockSeek;
@@ -2125,10 +2742,10 @@ var InteractiveVideoInner = forwardRef9(function InteractiveVideoInner2(props, r
2125
2742
  }
2126
2743
  };
2127
2744
  const completeCue = () => {
2128
- const cue = sortedCues[index];
2745
+ const cue = sortedCues[visibleIndex];
2129
2746
  if (!cue || !cueCanContinue(cue)) return;
2130
2747
  setCompletedCues((prev) => {
2131
- const next = /* @__PURE__ */ new Set([...prev, index]);
2748
+ const next = /* @__PURE__ */ new Set([...prev, visibleIndex]);
2132
2749
  completedCuesRef.current = next;
2133
2750
  return next;
2134
2751
  });
@@ -2138,7 +2755,7 @@ var InteractiveVideoInner = forwardRef9(function InteractiveVideoInner2(props, r
2138
2755
  "video_segment_completed",
2139
2756
  {
2140
2757
  blockId,
2141
- segmentIndex: index,
2758
+ segmentIndex: visibleIndex,
2142
2759
  atSeconds: cue.props.atSeconds ?? 0,
2143
2760
  segmentLabel: cue.props.label
2144
2761
  },
@@ -2157,12 +2774,12 @@ var InteractiveVideoInner = forwardRef9(function InteractiveVideoInner2(props, r
2157
2774
  " ",
2158
2775
  Array.from(ctx.getHandles().values()).reduce((s, h) => s + h.getMaxScore(), 0)
2159
2776
  ] }) : null,
2160
- /* @__PURE__ */ jsx16("div", { style: { position: "relative" }, children: /* @__PURE__ */ jsx16(
2777
+ !resolvedSrc ? /* @__PURE__ */ jsx16("p", { role: "alert", "data-testid": "interactive-video-blocked", children: "This video URL is not allowed." }) : /* @__PURE__ */ jsx16("div", { style: { position: "relative" }, children: /* @__PURE__ */ jsx16(
2161
2778
  "video",
2162
2779
  {
2163
2780
  ref: videoRef,
2164
- src: props.src,
2165
- poster: props.poster,
2781
+ src: resolvedSrc,
2782
+ poster: resolvedPoster ?? void 0,
2166
2783
  controls: true,
2167
2784
  "data-testid": "interactive-video-player",
2168
2785
  onTimeUpdate,
@@ -2174,13 +2791,22 @@ var InteractiveVideoInner = forwardRef9(function InteractiveVideoInner2(props, r
2174
2791
  video.currentTime = blockSeek;
2175
2792
  }
2176
2793
  },
2177
- children: props.captions ? /* @__PURE__ */ jsx16("track", { kind: "captions", src: props.captions, srcLang: "en", label: "Captions", default: true }) : null
2794
+ children: resolvedCaptions ? /* @__PURE__ */ jsx16(
2795
+ "track",
2796
+ {
2797
+ kind: "captions",
2798
+ src: resolvedCaptions,
2799
+ srcLang: "en",
2800
+ label: "Captions",
2801
+ default: true
2802
+ }
2803
+ ) : null
2178
2804
  }
2179
2805
  ) }),
2180
2806
  /* @__PURE__ */ jsx16("div", { "data-testid": "interactive-video-cues", children: sortedCues.map(
2181
2807
  (cue, i) => React15.cloneElement(cue, {
2182
2808
  key: cue.key ?? i,
2183
- hidden: !overlayActive || i !== index,
2809
+ hidden: !overlayActive || i !== visibleIndex,
2184
2810
  cueIndex: i,
2185
2811
  parentType: "InteractiveVideo"
2186
2812
  })
@@ -2203,15 +2829,21 @@ var InteractiveVideoInner = forwardRef9(function InteractiveVideoInner2(props, r
2203
2829
  });
2204
2830
  var InteractiveVideo = forwardRef9(
2205
2831
  function InteractiveVideo2(props, ref) {
2206
- const blockId = useMemo11(
2207
- () => normalizeComponentId(props.blockId, "blockId"),
2208
- [props.blockId]
2209
- );
2210
2832
  const cues = React15.Children.toArray(props.children).filter(
2211
2833
  React15.isValidElement
2212
2834
  );
2835
+ const sortedCues = useMemo11(() => sortCuesByTime(cues), [cues]);
2213
2836
  const { config, storage } = useLessonkit();
2214
2837
  const persistEnabled = config.session?.persistCompoundState !== false;
2838
+ requireCompoundBlockIdWhenPersisting({
2839
+ persistEnabled,
2840
+ blockId: props.blockId,
2841
+ componentName: "InteractiveVideo"
2842
+ });
2843
+ const blockId = useMemo11(
2844
+ () => normalizeComponentId(props.blockId, "blockId"),
2845
+ [props.blockId]
2846
+ );
2215
2847
  const initialMeta = useMemo11(
2216
2848
  () => loadVideoMeta(storage, config.courseId, blockId, persistEnabled),
2217
2849
  [storage, config.courseId, blockId, persistEnabled]
@@ -2219,22 +2851,22 @@ var InteractiveVideo = forwardRef9(
2219
2851
  const initialIndex = useCompoundInitialIndex({
2220
2852
  courseId: config.courseId,
2221
2853
  compoundId: blockId,
2222
- pageCount: cues.length,
2854
+ pageCount: sortedCues.length,
2223
2855
  persistEnabled,
2224
2856
  storage
2225
2857
  });
2226
2858
  const [index, setIndex] = useState9(initialIndex);
2227
- const setIndexStable = useCallback7((i) => setIndex(i), []);
2859
+ const setIndexStable = useCallback8((i) => setIndex(i), []);
2228
2860
  useEffect15(() => {
2229
2861
  setIndex(initialIndex);
2230
- }, [config.courseId, blockId, initialIndex]);
2862
+ }, [config.courseId, blockId, initialIndex, sortedCues.length]);
2231
2863
  return /* @__PURE__ */ jsx16(CompoundProvider, { activePageIndex: index, onActivePageIndexChange: setIndexStable, children: /* @__PURE__ */ jsx16(
2232
2864
  InteractiveVideoInner,
2233
2865
  {
2234
2866
  ...props,
2235
2867
  ref,
2236
2868
  blockId,
2237
- cues,
2869
+ cues: sortedCues,
2238
2870
  index,
2239
2871
  setIndex,
2240
2872
  persistEnabled,
@@ -2247,10 +2879,18 @@ setLessonkitBlockType(InteractiveVideo, "InteractiveVideo");
2247
2879
 
2248
2880
  // src/blocks/Summary.tsx
2249
2881
  import { forwardRef as forwardRef10, useEffect as useEffect16, useMemo as useMemo12, useRef as useRef12, useState as useState10 } from "react";
2882
+
2883
+ // src/assessment/shouldReplayResumeTelemetry.ts
2884
+ function shouldReplayResumeTelemetry(config) {
2885
+ return config?.tracking?.replayResumeEvents === true;
2886
+ }
2887
+
2888
+ // src/blocks/Summary.tsx
2250
2889
  import { jsx as jsx17, jsxs as jsxs14 } from "react/jsx-runtime";
2251
2890
  var INTERACTION6 = "summary";
2252
2891
  function SummaryInner(props, ref) {
2253
2892
  const checkId = useMemo12(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
2893
+ const { config } = useLessonkit();
2254
2894
  const assessment = useAssessmentState(props.enclosingLessonId);
2255
2895
  const [selectedIndices, setSelectedIndices] = useState10([]);
2256
2896
  const [passed, setPassed] = useState10(false);
@@ -2278,7 +2918,7 @@ function SummaryInner(props, ref) {
2278
2918
  const handle = useMemo12(
2279
2919
  () => buildAssessmentHandle({
2280
2920
  checkId,
2281
- getScore: () => passed ? score : 0,
2921
+ getScore: () => score,
2282
2922
  getMaxScore: () => maxScore,
2283
2923
  getAnswerGiven: () => selectedIndices.length > 0,
2284
2924
  resetTask: reset,
@@ -2289,7 +2929,7 @@ function SummaryInner(props, ref) {
2289
2929
  interactionType: INTERACTION6,
2290
2930
  response: selected,
2291
2931
  correct: passedThreshold,
2292
- score: passed ? score : 0,
2932
+ score,
2293
2933
  maxScore
2294
2934
  }),
2295
2935
  getCurrentState: () => ({ selectedIndices, passed, checked }),
@@ -2299,34 +2939,48 @@ function SummaryInner(props, ref) {
2299
2939
  nextIndices = [...state.selectedIndices];
2300
2940
  } else if (Array.isArray(state.selected)) {
2301
2941
  const legacy = state.selected;
2942
+ if (isDevEnvironment()) {
2943
+ const seen = /* @__PURE__ */ new Set();
2944
+ for (const text of props.statements) {
2945
+ if (seen.has(text)) {
2946
+ console.warn(
2947
+ "[lessonkit] Summary: duplicate statement strings; legacy selected resume may be ambiguous",
2948
+ text
2949
+ );
2950
+ break;
2951
+ }
2952
+ seen.add(text);
2953
+ }
2954
+ }
2302
2955
  nextIndices = legacy.map((text) => props.statements.indexOf(text)).filter((i) => i >= 0);
2303
2956
  }
2304
2957
  setSelectedIndices(nextIndices);
2305
2958
  const nextSelected = nextIndices.map((i) => props.statements[i] ?? "");
2306
2959
  const nextIsCorrect = nextSelected.length === props.correct.length && nextSelected.every((s, i) => s === props.correct[i]);
2307
2960
  const nextScore = nextIsCorrect ? maxScore : 0;
2308
- readBooleanStateField(state, "passed", (value) => {
2309
- setPassed(value);
2310
- completedRef.current = value;
2311
- if (value) {
2312
- if (!telemetryReplayedRef.current) {
2313
- telemetryReplayedRef.current = true;
2314
- assessment.answer({
2315
- checkId,
2316
- interactionType: INTERACTION6,
2317
- response: nextSelected,
2318
- correct: true
2319
- });
2320
- assessment.complete({
2321
- checkId,
2322
- interactionType: INTERACTION6,
2323
- score: nextScore,
2324
- maxScore,
2325
- passingScore: props.passingScore ?? maxScore
2326
- });
2327
- }
2328
- }
2329
- });
2961
+ const nextPassedThreshold = meetsPassingThreshold(
2962
+ nextScore,
2963
+ maxScore,
2964
+ props.passingScore
2965
+ );
2966
+ setPassed(nextPassedThreshold);
2967
+ completedRef.current = nextPassedThreshold;
2968
+ if (nextPassedThreshold && !telemetryReplayedRef.current && shouldReplayResumeTelemetry(config)) {
2969
+ telemetryReplayedRef.current = true;
2970
+ assessment.answer({
2971
+ checkId,
2972
+ interactionType: INTERACTION6,
2973
+ response: nextSelected,
2974
+ correct: nextPassedThreshold
2975
+ });
2976
+ assessment.complete({
2977
+ checkId,
2978
+ interactionType: INTERACTION6,
2979
+ score: nextScore,
2980
+ maxScore,
2981
+ passingScore: props.passingScore ?? maxScore
2982
+ });
2983
+ }
2330
2984
  readBooleanStateField(state, "checked", setChecked);
2331
2985
  }
2332
2986
  }),
@@ -2334,14 +2988,16 @@ function SummaryInner(props, ref) {
2334
2988
  assessment,
2335
2989
  checkId,
2336
2990
  checked,
2991
+ config,
2337
2992
  maxScore,
2338
2993
  passed,
2339
2994
  passedThreshold,
2995
+ props.correct,
2340
2996
  props.passingScore,
2341
2997
  props.statements,
2342
2998
  score,
2343
2999
  selected,
2344
- selectedIndices.length
3000
+ selectedIndices
2345
3001
  ]
2346
3002
  );
2347
3003
  useAssessmentHandleRegistration(checkId, handle, ref);
@@ -2364,9 +3020,9 @@ function SummaryInner(props, ref) {
2364
3020
  response: selected,
2365
3021
  correct: passedThreshold
2366
3022
  });
2367
- if (passedThreshold && !completedRef.current) {
3023
+ if ((passedThreshold || props.enableRetry === false) && !completedRef.current) {
2368
3024
  completedRef.current = true;
2369
- setPassed(true);
3025
+ if (passedThreshold) setPassed(true);
2370
3026
  assessment.complete({
2371
3027
  checkId,
2372
3028
  interactionType: INTERACTION6,
@@ -2444,8 +3100,34 @@ function buildDeck(pairs) {
2444
3100
  );
2445
3101
  return shuffleCards(cards);
2446
3102
  }
3103
+ function rebuildCardsFromKeys(pairs, cardKeys) {
3104
+ const pairMap = new Map(pairs.map((pair) => [pair.id, pair]));
3105
+ if (cardKeys.length !== pairs.length * 2) return null;
3106
+ const seen = /* @__PURE__ */ new Set();
3107
+ const cards = [];
3108
+ for (const cardKey of cardKeys) {
3109
+ if (seen.has(cardKey)) return null;
3110
+ seen.add(cardKey);
3111
+ const match = /^(.+)-([01])$/.exec(cardKey);
3112
+ if (!match) return null;
3113
+ const pairId = match[1];
3114
+ const copy = Number(match[2]);
3115
+ if (copy !== 0 && copy !== 1) return null;
3116
+ const pair = pairMap.get(pairId);
3117
+ if (!pair) return null;
3118
+ cards.push({
3119
+ cardKey,
3120
+ pairId: pair.id,
3121
+ label: pair.label,
3122
+ imageSrc: pair.imageSrc
3123
+ });
3124
+ }
3125
+ return cards;
3126
+ }
2447
3127
  function ImagePairingInner(props, ref) {
2448
3128
  const checkId = useMemo13(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
3129
+ const { config } = useLessonkit();
3130
+ const mediaOptions = { allowedHosts: config.embed?.allowedHosts };
2449
3131
  const assessment = useAssessmentState(props.enclosingLessonId);
2450
3132
  const pairsKey = props.pairs.map((p) => p.id).join("\0");
2451
3133
  const [cards, setCards] = useState11(() => buildDeck(props.pairs));
@@ -2453,9 +3135,15 @@ function ImagePairingInner(props, ref) {
2453
3135
  const [revealed, setRevealed] = useState11(() => /* @__PURE__ */ new Set());
2454
3136
  const [keyboardSelection, setKeyboardSelection] = useState11(null);
2455
3137
  const [passed, setPassed] = useState11(false);
3138
+ const [submitted, setSubmitted] = useState11(false);
2456
3139
  const completedRef = useRef13(false);
2457
3140
  const telemetryReplayedRef = useRef13(false);
3141
+ const mismatchTimeoutRef = useRef13(null);
2458
3142
  const reset = () => {
3143
+ if (mismatchTimeoutRef.current !== null) {
3144
+ window.clearTimeout(mismatchTimeoutRef.current);
3145
+ mismatchTimeoutRef.current = null;
3146
+ }
2459
3147
  completedRef.current = false;
2460
3148
  telemetryReplayedRef.current = false;
2461
3149
  setCards(buildDeck(props.pairs));
@@ -2463,10 +3151,19 @@ function ImagePairingInner(props, ref) {
2463
3151
  setRevealed(/* @__PURE__ */ new Set());
2464
3152
  setKeyboardSelection(null);
2465
3153
  setPassed(false);
3154
+ setSubmitted(false);
2466
3155
  };
2467
3156
  useEffect17(() => {
2468
3157
  reset();
2469
3158
  }, [checkId, pairsKey]);
3159
+ useEffect17(
3160
+ () => () => {
3161
+ if (mismatchTimeoutRef.current !== null) {
3162
+ window.clearTimeout(mismatchTimeoutRef.current);
3163
+ }
3164
+ },
3165
+ []
3166
+ );
2470
3167
  const totalPairs = props.pairs.length;
2471
3168
  const matchedCount = matched.size;
2472
3169
  const maxScore = totalPairs || 1;
@@ -2474,25 +3171,34 @@ function ImagePairingInner(props, ref) {
2474
3171
  const allMatched = totalPairs > 0 && matchedCount === totalPairs;
2475
3172
  const passedThreshold = meetsPassingThreshold(score, maxScore, props.passingScore);
2476
3173
  const completeIfReady = (nextMatched) => {
2477
- if (nextMatched.size === totalPairs && totalPairs > 0 && !completedRef.current) {
2478
- const finalScore = nextMatched.size;
2479
- const finalPassed = meetsPassingThreshold(finalScore, maxScore, props.passingScore);
2480
- completedRef.current = true;
2481
- setPassed(true);
2482
- assessment.answer({
2483
- checkId,
2484
- interactionType: INTERACTION7,
2485
- response: { matchedPairIds: [...nextMatched] },
2486
- correct: finalPassed
2487
- });
2488
- assessment.complete({
2489
- checkId,
2490
- interactionType: INTERACTION7,
2491
- score: finalScore,
2492
- maxScore,
2493
- passingScore: props.passingScore ?? maxScore
2494
- });
2495
- }
3174
+ if (totalPairs === 0 || completedRef.current) return;
3175
+ const finalScore = nextMatched.size;
3176
+ const finalPassed = meetsPassingThreshold(finalScore, maxScore, props.passingScore);
3177
+ if (!finalPassed && nextMatched.size < totalPairs) return;
3178
+ completeWithScore(nextMatched, finalScore, finalPassed);
3179
+ };
3180
+ const completeWithScore = (nextMatched, finalScore, finalPassed) => {
3181
+ if (completedRef.current) return;
3182
+ completedRef.current = true;
3183
+ setSubmitted(true);
3184
+ setPassed(finalPassed);
3185
+ assessment.answer({
3186
+ checkId,
3187
+ interactionType: INTERACTION7,
3188
+ response: { matchedPairIds: [...nextMatched] },
3189
+ correct: finalPassed
3190
+ });
3191
+ assessment.complete({
3192
+ checkId,
3193
+ interactionType: INTERACTION7,
3194
+ score: finalScore,
3195
+ maxScore,
3196
+ passingScore: props.passingScore ?? maxScore
3197
+ });
3198
+ };
3199
+ const finishAttempt = () => {
3200
+ if (completedRef.current || matchedCount === 0) return;
3201
+ completeWithScore(matched, matchedCount, passedThreshold);
2496
3202
  };
2497
3203
  const tryMatch = (firstKey, secondKey) => {
2498
3204
  if (firstKey === secondKey) return;
@@ -2509,7 +3215,11 @@ function ImagePairingInner(props, ref) {
2509
3215
  setRevealed(/* @__PURE__ */ new Set());
2510
3216
  setKeyboardSelection(null);
2511
3217
  } else {
2512
- window.setTimeout(() => {
3218
+ if (mismatchTimeoutRef.current !== null) {
3219
+ window.clearTimeout(mismatchTimeoutRef.current);
3220
+ }
3221
+ mismatchTimeoutRef.current = window.setTimeout(() => {
3222
+ mismatchTimeoutRef.current = null;
2513
3223
  setRevealed((prev) => {
2514
3224
  const next = new Set(prev);
2515
3225
  next.delete(firstKey);
@@ -2552,17 +3262,22 @@ function ImagePairingInner(props, ref) {
2552
3262
  checkId,
2553
3263
  interactionType: INTERACTION7,
2554
3264
  response: { matchedPairIds: [...matched] },
2555
- correct: allMatched && passedThreshold,
3265
+ correct: passedThreshold,
2556
3266
  score,
2557
3267
  maxScore
2558
3268
  }),
2559
3269
  getCurrentState: () => ({
3270
+ cardKeys: cards.map((card) => card.cardKey),
2560
3271
  matched: [...matched],
2561
3272
  revealed: [...revealed],
2562
3273
  keyboardSelection,
2563
3274
  passed
2564
3275
  }),
2565
3276
  resume: (state) => {
3277
+ if (Array.isArray(state.cardKeys)) {
3278
+ const restored = rebuildCardsFromKeys(props.pairs, state.cardKeys);
3279
+ if (restored) setCards(restored);
3280
+ }
2566
3281
  if (Array.isArray(state.matched)) setMatched(new Set(state.matched));
2567
3282
  if (Array.isArray(state.revealed)) setRevealed(new Set(state.revealed));
2568
3283
  const sel = state.keyboardSelection;
@@ -2570,15 +3285,20 @@ function ImagePairingInner(props, ref) {
2570
3285
  readBooleanStateField(state, "passed", (value) => {
2571
3286
  setPassed(value);
2572
3287
  completedRef.current = value;
2573
- if (value && !telemetryReplayedRef.current) {
3288
+ if (value && !telemetryReplayedRef.current && shouldReplayResumeTelemetry(config)) {
2574
3289
  telemetryReplayedRef.current = true;
2575
3290
  const matchedIds = Array.isArray(state.matched) ? state.matched : [...matched];
2576
3291
  const finalScore = matchedIds.length;
3292
+ const finalPassed = meetsPassingThreshold(
3293
+ finalScore,
3294
+ maxScore,
3295
+ props.passingScore
3296
+ );
2577
3297
  assessment.answer({
2578
3298
  checkId,
2579
3299
  interactionType: INTERACTION7,
2580
3300
  response: { matchedPairIds: matchedIds },
2581
- correct: true
3301
+ correct: finalPassed
2582
3302
  });
2583
3303
  assessment.complete({
2584
3304
  checkId,
@@ -2591,7 +3311,7 @@ function ImagePairingInner(props, ref) {
2591
3311
  });
2592
3312
  }
2593
3313
  }),
2594
- [allMatched, checkId, keyboardSelection, matched, matchedCount, maxScore, passed, passedThreshold, revealed, score]
3314
+ [allMatched, assessment, cards, checkId, config, keyboardSelection, matched, matchedCount, maxScore, passed, passedThreshold, props.pairs, props.passingScore, revealed, score]
2595
3315
  );
2596
3316
  useAssessmentHandleRegistration(checkId, handle, ref);
2597
3317
  return /* @__PURE__ */ jsxs15("section", { "aria-label": "Image Pairing", "data-lk-check-id": checkId, "data-testid": "image-pairing", children: [
@@ -2600,6 +3320,7 @@ function ImagePairingInner(props, ref) {
2600
3320
  const isMatched = matched.has(card.pairId);
2601
3321
  const isRevealed = isMatched || revealed.has(card.cardKey);
2602
3322
  const isSelected = keyboardSelection === card.cardKey;
3323
+ const resolvedCardSrc = resolveMediaSrc(card.imageSrc, mediaOptions);
2603
3324
  return /* @__PURE__ */ jsx18(
2604
3325
  "button",
2605
3326
  {
@@ -2616,7 +3337,14 @@ function ImagePairingInner(props, ref) {
2616
3337
  border: isSelected ? "2px solid currentColor" : "1px solid currentColor"
2617
3338
  },
2618
3339
  children: isRevealed ? /* @__PURE__ */ jsxs15(Fragment2, { children: [
2619
- /* @__PURE__ */ jsx18("img", { src: card.imageSrc, alt: card.label, style: { maxWidth: "5rem", maxHeight: "5rem" } }),
3340
+ resolvedCardSrc ? /* @__PURE__ */ jsx18(
3341
+ "img",
3342
+ {
3343
+ src: resolvedCardSrc,
3344
+ alt: card.label,
3345
+ style: { maxWidth: "5rem", maxHeight: "5rem" }
3346
+ }
3347
+ ) : /* @__PURE__ */ jsx18("span", { "aria-hidden": "true", children: "!" }),
2620
3348
  /* @__PURE__ */ jsx18("span", { className: "lk-visually-hidden", children: card.label })
2621
3349
  ] }) : "?"
2622
3350
  },
@@ -2629,6 +3357,7 @@ function ImagePairingInner(props, ref) {
2629
3357
  totalPairs,
2630
3358
  " pairs matched"
2631
3359
  ] }),
3360
+ props.enableRetry === false && matchedCount > 0 && !submitted ? /* @__PURE__ */ jsx18("button", { type: "button", "data-testid": "image-pairing-finish", onClick: finishAttempt, children: "Submit" }) : null,
2632
3361
  props.enableRetry && passed ? /* @__PURE__ */ jsx18("button", { type: "button", "data-testid": "image-pairing-retry", onClick: reset, children: "Try again" }) : null
2633
3362
  ] });
2634
3363
  }
@@ -2644,6 +3373,8 @@ import { jsx as jsx19, jsxs as jsxs16 } from "react/jsx-runtime";
2644
3373
  var INTERACTION8 = "imageSequencing";
2645
3374
  function ImageSequencingInner(props, ref) {
2646
3375
  const checkId = useMemo14(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
3376
+ const { config } = useLessonkit();
3377
+ const mediaOptions = { allowedHosts: config.embed?.allowedHosts };
2647
3378
  const assessment = useAssessmentState(props.enclosingLessonId);
2648
3379
  const imagesKey = props.images.map((i) => i.id).join("\0");
2649
3380
  const orderKey = props.correctOrder.join("\0");
@@ -2680,9 +3411,9 @@ function ImageSequencingInner(props, ref) {
2680
3411
  const handle = useMemo14(
2681
3412
  () => buildAssessmentHandle({
2682
3413
  checkId,
2683
- getScore: () => passed ? score : 0,
3414
+ getScore: () => score,
2684
3415
  getMaxScore: () => maxScore,
2685
- getAnswerGiven: () => order.length > 0,
3416
+ getAnswerGiven: () => checked,
2686
3417
  resetTask: reset,
2687
3418
  showSolutions: () => {
2688
3419
  },
@@ -2691,7 +3422,7 @@ function ImageSequencingInner(props, ref) {
2691
3422
  interactionType: INTERACTION8,
2692
3423
  response: order,
2693
3424
  correct: passedThreshold,
2694
- score: passed ? score : 0,
3425
+ score,
2695
3426
  maxScore
2696
3427
  }),
2697
3428
  getCurrentState: () => ({ order, passed, checked }),
@@ -2704,7 +3435,7 @@ function ImageSequencingInner(props, ref) {
2704
3435
  readBooleanStateField(state, "passed", (value) => {
2705
3436
  setPassed(value);
2706
3437
  completedRef.current = value;
2707
- if (value && !telemetryReplayedRef.current) {
3438
+ if (value && !telemetryReplayedRef.current && shouldReplayResumeTelemetry(config)) {
2708
3439
  telemetryReplayedRef.current = true;
2709
3440
  const nextIsCorrect = nextOrder.every((id, i) => id === props.correctOrder[i]);
2710
3441
  const nextScore = nextIsCorrect ? maxScore : 0;
@@ -2726,7 +3457,7 @@ function ImageSequencingInner(props, ref) {
2726
3457
  readBooleanStateField(state, "checked", setChecked);
2727
3458
  }
2728
3459
  }),
2729
- [checkId, checked, maxScore, order, passed, passedThreshold, score]
3460
+ [assessment, checkId, checked, config, maxScore, order, passed, passedThreshold, props.correctOrder, props.passingScore, score]
2730
3461
  );
2731
3462
  useAssessmentHandleRegistration(checkId, handle, ref);
2732
3463
  const check = () => {
@@ -2737,9 +3468,9 @@ function ImageSequencingInner(props, ref) {
2737
3468
  response: order,
2738
3469
  correct: passedThreshold
2739
3470
  });
2740
- if (passedThreshold && !completedRef.current) {
3471
+ if ((passedThreshold || props.enableRetry === false) && !completedRef.current) {
2741
3472
  completedRef.current = true;
2742
- setPassed(true);
3473
+ if (passedThreshold) setPassed(true);
2743
3474
  assessment.complete({
2744
3475
  checkId,
2745
3476
  interactionType: INTERACTION8,
@@ -2754,8 +3485,16 @@ function ImageSequencingInner(props, ref) {
2754
3485
  /* @__PURE__ */ jsx19("ol", { "data-testid": "image-sequencing-list", children: order.map((id, index) => {
2755
3486
  const image = props.images.find((i) => i.id === id);
2756
3487
  if (!image) return null;
3488
+ const resolvedSrc = resolveMediaSrc(image.src, mediaOptions);
2757
3489
  return /* @__PURE__ */ jsxs16("li", { "data-testid": `sequencing-item-${id}`, children: [
2758
- /* @__PURE__ */ jsx19("img", { src: image.src, alt: image.alt, style: { maxWidth: "8rem", verticalAlign: "middle" } }),
3490
+ resolvedSrc ? /* @__PURE__ */ jsx19(
3491
+ "img",
3492
+ {
3493
+ src: resolvedSrc,
3494
+ alt: image.alt,
3495
+ style: { maxWidth: "8rem", verticalAlign: "middle" }
3496
+ }
3497
+ ) : /* @__PURE__ */ jsx19("span", { "aria-hidden": "true", children: "!" }),
2759
3498
  /* @__PURE__ */ jsx19(
2760
3499
  "button",
2761
3500
  {
@@ -2803,11 +3542,12 @@ var ImageSequencing = forwardRef12(
2803
3542
  setLessonkitBlockType(ImageSequencing, "ImageSequencing");
2804
3543
 
2805
3544
  // src/blocks/ArithmeticQuiz.tsx
2806
- import { forwardRef as forwardRef13, useCallback as useCallback8, useEffect as useEffect19, useMemo as useMemo15, useRef as useRef15, useState as useState13 } from "react";
3545
+ import { forwardRef as forwardRef13, useCallback as useCallback9, useEffect as useEffect19, useMemo as useMemo15, useRef as useRef15, useState as useState13 } from "react";
2807
3546
  import { jsx as jsx20, jsxs as jsxs17 } from "react/jsx-runtime";
2808
3547
  var INTERACTION9 = "arithmeticQuiz";
2809
3548
  function ArithmeticQuizInner(props, ref) {
2810
3549
  const checkId = useMemo15(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
3550
+ const { config } = useLessonkit();
2811
3551
  const assessment = useAssessmentState(props.enclosingLessonId);
2812
3552
  const problemsKey = props.problems.map((p) => `${p.question}\0${p.answer}`).join("|");
2813
3553
  const [answers, setAnswers] = useState13(
@@ -2838,7 +3578,7 @@ function ArithmeticQuizInner(props, ref) {
2838
3578
  const maxScore = props.problems.length || 1;
2839
3579
  const passedThreshold = meetsPassingThreshold(score, maxScore, props.passingScore);
2840
3580
  const allFilled = props.problems.every((_, i) => (answers[i] ?? "").trim().length > 0);
2841
- const runCheck = useCallback8(
3581
+ const runCheck = useCallback9(
2842
3582
  (force = false) => {
2843
3583
  if (!force && !allFilled) return;
2844
3584
  setChecked(true);
@@ -2848,9 +3588,9 @@ function ArithmeticQuizInner(props, ref) {
2848
3588
  response: answers,
2849
3589
  correct: passedThreshold
2850
3590
  });
2851
- if (passedThreshold && !completedRef.current) {
3591
+ if ((passedThreshold || props.enableRetry === false) && !completedRef.current) {
2852
3592
  completedRef.current = true;
2853
- setPassed(true);
3593
+ setPassed(passedThreshold);
2854
3594
  assessment.complete({
2855
3595
  checkId,
2856
3596
  interactionType: INTERACTION9,
@@ -2874,7 +3614,7 @@ function ArithmeticQuizInner(props, ref) {
2874
3614
  const handle = useMemo15(
2875
3615
  () => buildAssessmentHandle({
2876
3616
  checkId,
2877
- getScore: () => passed ? score : 0,
3617
+ getScore: () => score,
2878
3618
  getMaxScore: () => maxScore,
2879
3619
  getAnswerGiven: () => allFilled,
2880
3620
  resetTask: reset,
@@ -2885,7 +3625,7 @@ function ArithmeticQuizInner(props, ref) {
2885
3625
  interactionType: INTERACTION9,
2886
3626
  response: answers,
2887
3627
  correct: passedThreshold,
2888
- score: passed ? score : 0,
3628
+ score,
2889
3629
  maxScore
2890
3630
  }),
2891
3631
  getCurrentState: () => ({ answers, passed, checked, timeLeft }),
@@ -2899,17 +3639,18 @@ function ArithmeticQuizInner(props, ref) {
2899
3639
  readBooleanStateField(state, "passed", (value) => {
2900
3640
  setPassed(value);
2901
3641
  completedRef.current = value;
2902
- if (value && !telemetryReplayedRef.current) {
3642
+ if (value && !telemetryReplayedRef.current && shouldReplayResumeTelemetry(config)) {
2903
3643
  telemetryReplayedRef.current = true;
2904
3644
  let nextScore = 0;
2905
3645
  props.problems.forEach((p, i) => {
2906
3646
  if ((nextAnswers[i] ?? "").trim() === p.answer.trim()) nextScore += 1;
2907
3647
  });
3648
+ const replayCorrect = nextScore >= (props.passingScore ?? maxScore);
2908
3649
  assessment.answer({
2909
3650
  checkId,
2910
3651
  interactionType: INTERACTION9,
2911
3652
  response: nextAnswers,
2912
- correct: true
3653
+ correct: replayCorrect
2913
3654
  });
2914
3655
  assessment.complete({
2915
3656
  checkId,
@@ -2924,7 +3665,7 @@ function ArithmeticQuizInner(props, ref) {
2924
3665
  if (typeof state.timeLeft === "number") setTimeLeft(state.timeLeft);
2925
3666
  }
2926
3667
  }),
2927
- [allFilled, answers, checkId, checked, maxScore, passed, passedThreshold, score, timeLeft]
3668
+ [allFilled, answers, checkId, checked, config, maxScore, passed, passedThreshold, props.problems, props.passingScore, score, timeLeft]
2928
3669
  );
2929
3670
  useAssessmentHandleRegistration(checkId, handle, ref);
2930
3671
  const onInput = (index, value) => {
@@ -2988,6 +3729,7 @@ import { jsx as jsx21, jsxs as jsxs18 } from "react/jsx-runtime";
2988
3729
  var INTERACTION10 = "essay";
2989
3730
  function EssayInner(props, ref) {
2990
3731
  const checkId = useMemo16(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
3732
+ const { config } = useLessonkit();
2991
3733
  const assessment = useAssessmentState(props.enclosingLessonId);
2992
3734
  const [text, setText] = useState14("");
2993
3735
  const [submitted, setSubmitted] = useState14(false);
@@ -3027,16 +3769,22 @@ function EssayInner(props, ref) {
3027
3769
  const nextText = readStringField(state, "text");
3028
3770
  if (typeof nextText === "string") setText(nextText);
3029
3771
  readBooleanStateField(state, "submitted", (value) => {
3772
+ const textVal = typeof nextText === "string" ? nextText : text;
3773
+ const meetsMin = textVal.trim().length >= minLength;
3774
+ if (value && !meetsMin) {
3775
+ setSubmitted(false);
3776
+ completedRef.current = false;
3777
+ return;
3778
+ }
3030
3779
  setSubmitted(value);
3031
3780
  completedRef.current = value;
3032
- if (value && !telemetryReplayedRef.current) {
3781
+ if (value && !telemetryReplayedRef.current && shouldReplayResumeTelemetry(config)) {
3033
3782
  telemetryReplayedRef.current = true;
3034
- const response = typeof nextText === "string" ? nextText : text;
3035
3783
  assessment.answer({
3036
3784
  checkId,
3037
3785
  interactionType: INTERACTION10,
3038
3786
  question: props.question,
3039
- response,
3787
+ response: textVal,
3040
3788
  correct: false
3041
3789
  });
3042
3790
  assessment.complete({
@@ -3050,7 +3798,7 @@ function EssayInner(props, ref) {
3050
3798
  });
3051
3799
  }
3052
3800
  }),
3053
- [checkId, meetsMinLength, props.question, submitted, text]
3801
+ [assessment, checkId, config, meetsMinLength, minLength, props.passingScore, props.question, submitted, text]
3054
3802
  );
3055
3803
  useAssessmentHandleRegistration(checkId, handle, ref);
3056
3804
  const submit = () => {
@@ -3201,7 +3949,7 @@ function Questionnaire(props) {
3201
3949
  setLessonkitBlockType(Questionnaire, "Questionnaire");
3202
3950
 
3203
3951
  // src/blocks/MemoryGame.tsx
3204
- import { useEffect as useEffect22, useMemo as useMemo18, useState as useState16 } from "react";
3952
+ import { useEffect as useEffect22, useMemo as useMemo18, useRef as useRef17, useState as useState16 } from "react";
3205
3953
  import { jsx as jsx23, jsxs as jsxs20 } from "react/jsx-runtime";
3206
3954
  function shuffleCards2(cards) {
3207
3955
  const next = [...cards];
@@ -3228,16 +3976,29 @@ function MemoryGame(props) {
3228
3976
  const [revealed, setRevealed] = useState16(() => /* @__PURE__ */ new Set());
3229
3977
  const [selection, setSelection] = useState16(null);
3230
3978
  const [complete, setComplete] = useState16(false);
3979
+ const mismatchTimeoutRef = useRef17(null);
3231
3980
  const { track } = useLessonkit();
3232
3981
  const lessonId = useEnclosingLessonId();
3233
3982
  const trackOpts = lessonId ? { lessonId } : void 0;
3234
3983
  useEffect22(() => {
3984
+ if (mismatchTimeoutRef.current !== null) {
3985
+ window.clearTimeout(mismatchTimeoutRef.current);
3986
+ mismatchTimeoutRef.current = null;
3987
+ }
3235
3988
  setCards(buildDeck2(props.pairs));
3236
3989
  setMatched(/* @__PURE__ */ new Set());
3237
3990
  setRevealed(/* @__PURE__ */ new Set());
3238
3991
  setSelection(null);
3239
3992
  setComplete(false);
3240
3993
  }, [props.blockId, pairsKey]);
3994
+ useEffect22(
3995
+ () => () => {
3996
+ if (mismatchTimeoutRef.current !== null) {
3997
+ window.clearTimeout(mismatchTimeoutRef.current);
3998
+ }
3999
+ },
4000
+ []
4001
+ );
3241
4002
  const cardIndexByKey = useMemo18(
3242
4003
  () => Object.fromEntries(cards.map((c, i) => [c.cardKey, i])),
3243
4004
  [cards]
@@ -3267,7 +4028,11 @@ function MemoryGame(props) {
3267
4028
  setRevealed(/* @__PURE__ */ new Set());
3268
4029
  setSelection(null);
3269
4030
  } else {
3270
- window.setTimeout(() => {
4031
+ if (mismatchTimeoutRef.current !== null) {
4032
+ window.clearTimeout(mismatchTimeoutRef.current);
4033
+ }
4034
+ mismatchTimeoutRef.current = window.setTimeout(() => {
4035
+ mismatchTimeoutRef.current = null;
3271
4036
  setRevealed((prev) => {
3272
4037
  const next = new Set(prev);
3273
4038
  next.delete(firstKey);
@@ -3341,7 +4106,7 @@ function MemoryGame(props) {
3341
4106
  setLessonkitBlockType(MemoryGame, "MemoryGame");
3342
4107
 
3343
4108
  // src/blocks/InformationWall.tsx
3344
- import { useEffect as useEffect23, useMemo as useMemo19, useRef as useRef17, useState as useState17 } from "react";
4109
+ import { useEffect as useEffect23, useMemo as useMemo19, useRef as useRef18, useState as useState17 } from "react";
3345
4110
  import { jsx as jsx24, jsxs as jsxs21 } from "react/jsx-runtime";
3346
4111
  function InformationWall(props) {
3347
4112
  const blockId = useMemo19(
@@ -3352,7 +4117,7 @@ function InformationWall(props) {
3352
4117
  const { track } = useLessonkit();
3353
4118
  const lessonId = useEnclosingLessonId();
3354
4119
  const trackOpts = lessonId ? { lessonId } : void 0;
3355
- const debounceRef = useRef17(null);
4120
+ const debounceRef = useRef18(null);
3356
4121
  const filtered = useMemo19(() => {
3357
4122
  const q = query.trim().toLowerCase();
3358
4123
  if (!q) return props.panels;
@@ -3424,10 +4189,16 @@ function usePrefersReducedMotion() {
3424
4189
  function ParallaxSlideshow(props) {
3425
4190
  const [index, setIndex] = useState18(0);
3426
4191
  const reducedMotion = usePrefersReducedMotion();
3427
- const { track } = useLessonkit();
4192
+ const { track, config } = useLessonkit();
3428
4193
  const lessonId = useEnclosingLessonId();
3429
4194
  const trackOpts = lessonId ? { lessonId } : void 0;
3430
4195
  const slide = props.slides[index];
4196
+ const mediaOptions = { allowedHosts: config.embed?.allowedHosts };
4197
+ const resolvedImageSrc = slide?.imageSrc ? resolveMediaSrc(slide.imageSrc, mediaOptions) : null;
4198
+ useEffect24(() => {
4199
+ if (props.slides.length < 1) return;
4200
+ setIndex((current) => Math.min(current, props.slides.length - 1));
4201
+ }, [props.slides.length]);
3431
4202
  useEffect24(() => {
3432
4203
  track(
3433
4204
  "parallax_slide_viewed",
@@ -3454,22 +4225,23 @@ function ParallaxSlideshow(props) {
3454
4225
  "data-testid": `parallax-slide-${index}`,
3455
4226
  style: reducedMotion ? void 0 : {
3456
4227
  backgroundAttachment: "fixed",
3457
- backgroundImage: slide.imageSrc ? `url(${slide.imageSrc})` : void 0,
4228
+ backgroundImage: resolvedImageSrc ? `url("${resolvedImageSrc}")` : void 0,
3458
4229
  backgroundPosition: "center",
3459
4230
  backgroundSize: "cover",
3460
4231
  minHeight: "12rem",
3461
4232
  padding: "1rem"
3462
4233
  },
3463
4234
  children: [
3464
- reducedMotion && slide.imageSrc ? /* @__PURE__ */ jsx25(
4235
+ reducedMotion && resolvedImageSrc ? /* @__PURE__ */ jsx25(
3465
4236
  "img",
3466
4237
  {
3467
- src: slide.imageSrc,
4238
+ src: resolvedImageSrc,
3468
4239
  alt: "",
3469
4240
  "data-testid": "parallax-slide-image",
3470
4241
  style: { maxWidth: "100%" }
3471
4242
  }
3472
4243
  ) : null,
4244
+ !reducedMotion && slide.imageSrc && !resolvedImageSrc ? /* @__PURE__ */ jsx25("p", { role: "alert", children: "This image URL is not allowed." }) : null,
3473
4245
  /* @__PURE__ */ jsx25("h3", { "data-testid": "parallax-slide-title", children: slide.title }),
3474
4246
  /* @__PURE__ */ jsx25("p", { "data-testid": "parallax-slide-body", children: slide.body })
3475
4247
  ]
@@ -3658,8 +4430,9 @@ import { useState as useState22 } from "react";
3658
4430
  import { jsx as jsx29, jsxs as jsxs26 } from "react/jsx-runtime";
3659
4431
  function ImageHotspots(props) {
3660
4432
  const [active, setActive] = useState22(null);
3661
- const { track } = useLessonkit();
4433
+ const { track, config } = useLessonkit();
3662
4434
  const lessonId = useEnclosingLessonId();
4435
+ const resolvedSrc = resolveMediaSrc(props.src, { allowedHosts: config.embed?.allowedHosts });
3663
4436
  const open = (hotspotId) => {
3664
4437
  setActive(hotspotId);
3665
4438
  track(
@@ -3670,7 +4443,7 @@ function ImageHotspots(props) {
3670
4443
  };
3671
4444
  return /* @__PURE__ */ jsxs26("section", { "aria-label": "Image hotspots", "data-lk-block-id": props.blockId, "data-testid": "image-hotspots", children: [
3672
4445
  /* @__PURE__ */ jsxs26("div", { style: { position: "relative", display: "inline-block" }, children: [
3673
- /* @__PURE__ */ jsx29("img", { src: props.src, alt: props.alt, style: { maxWidth: "100%" } }),
4446
+ resolvedSrc ? /* @__PURE__ */ jsx29("img", { src: resolvedSrc, alt: props.alt, style: { maxWidth: "100%" } }) : /* @__PURE__ */ jsx29("p", { role: "alert", children: "This image URL is not allowed." }),
3674
4447
  props.hotspots.map((h) => /* @__PURE__ */ jsx29(
3675
4448
  "button",
3676
4449
  {
@@ -3703,9 +4476,11 @@ import { useState as useState23 } from "react";
3703
4476
  import { jsx as jsx30, jsxs as jsxs27 } from "react/jsx-runtime";
3704
4477
  function ImageSlider(props) {
3705
4478
  const [index, setIndex] = useState23(0);
3706
- const { track } = useLessonkit();
4479
+ const { track, config } = useLessonkit();
3707
4480
  const lessonId = useEnclosingLessonId();
3708
4481
  const slide = props.slides[index];
4482
+ const mediaOptions = { allowedHosts: config.embed?.allowedHosts };
4483
+ const resolvedSrc = slide ? resolveMediaSrc(slide.src, mediaOptions) : null;
3709
4484
  if (!slide) return null;
3710
4485
  const goTo = (next) => {
3711
4486
  setIndex(next);
@@ -3716,7 +4491,7 @@ function ImageSlider(props) {
3716
4491
  );
3717
4492
  };
3718
4493
  return /* @__PURE__ */ jsxs27("section", { "aria-label": "Image slider", "data-lk-block-id": props.blockId, "data-testid": "image-slider", children: [
3719
- /* @__PURE__ */ jsx30("img", { src: slide.src, alt: slide.alt, style: { maxWidth: "100%" } }),
4494
+ resolvedSrc ? /* @__PURE__ */ jsx30("img", { src: resolvedSrc, alt: slide.alt, style: { maxWidth: "100%" } }) : /* @__PURE__ */ jsx30("p", { role: "alert", children: "This image URL is not allowed." }),
3720
4495
  slide.caption ? /* @__PURE__ */ jsx30("p", { children: slide.caption }) : null,
3721
4496
  /* @__PURE__ */ jsxs27("nav", { "aria-label": "Slide navigation", children: [
3722
4497
  /* @__PURE__ */ jsx30(
@@ -3750,14 +4525,16 @@ function ImageSlider(props) {
3750
4525
  setLessonkitBlockType(ImageSlider, "ImageSlider");
3751
4526
 
3752
4527
  // src/blocks/FindHotspot.tsx
3753
- import { forwardRef as forwardRef15, useEffect as useEffect25, useMemo as useMemo20, useRef as useRef18, useState as useState24 } from "react";
4528
+ import { forwardRef as forwardRef15, useEffect as useEffect25, useMemo as useMemo20, useRef as useRef19, useState as useState24 } from "react";
3754
4529
  import { jsx as jsx31, jsxs as jsxs28 } from "react/jsx-runtime";
3755
4530
  var INTERACTION11 = "findHotspot";
3756
4531
  function FindHotspotInner(props, ref) {
3757
4532
  const checkId = useMemo20(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
4533
+ const { config } = useLessonkit();
4534
+ const resolvedSrc = resolveMediaSrc(props.src, { allowedHosts: config.embed?.allowedHosts });
3758
4535
  const [selected, setSelected] = useState24(null);
3759
4536
  const [checked, setChecked] = useState24(false);
3760
- const telemetryReplayedRef = useRef18(false);
4537
+ const telemetryReplayedRef = useRef19(false);
3761
4538
  const assessment = useAssessmentState(props.enclosingLessonId);
3762
4539
  const targetIdsKey = props.targets.map((t) => t.id).join("\0");
3763
4540
  useEffect25(() => {
@@ -3790,7 +4567,7 @@ function FindHotspotInner(props, ref) {
3790
4567
  checkId,
3791
4568
  getScore: () => checked && correct ? 1 : 0,
3792
4569
  getMaxScore: () => 1,
3793
- getAnswerGiven: () => selected !== null,
4570
+ getAnswerGiven: () => checked,
3794
4571
  resetTask: () => {
3795
4572
  setSelected(null);
3796
4573
  setChecked(false);
@@ -3820,10 +4597,12 @@ function FindHotspotInner(props, ref) {
3820
4597
  setChecked(value);
3821
4598
  });
3822
4599
  const nextCorrect = nextSelected === props.correctTargetId;
3823
- replayTelemetry(nextSelected, nextChecked, nextCorrect);
4600
+ if (config.tracking?.replayResumeEvents === true) {
4601
+ replayTelemetry(nextSelected, nextChecked, nextCorrect);
4602
+ }
3824
4603
  }
3825
4604
  }),
3826
- [assessment, checkId, checked, correct, props.correctTargetId, props.passingScore, props.targets, selected]
4605
+ [assessment, checkId, checked, config.tracking?.replayResumeEvents, correct, props.correctTargetId, props.passingScore, props.targets, selected]
3827
4606
  );
3828
4607
  useAssessmentHandleRegistration(checkId, handle, ref);
3829
4608
  const selectTarget = (id) => {
@@ -3847,11 +4626,19 @@ function FindHotspotInner(props, ref) {
3847
4626
  maxScore: 1,
3848
4627
  passingScore: props.passingScore ?? 1
3849
4628
  });
4629
+ } else if (props.enableRetry === false) {
4630
+ assessment.complete({
4631
+ checkId,
4632
+ interactionType: INTERACTION11,
4633
+ score: 0,
4634
+ maxScore: 1,
4635
+ passingScore: props.passingScore ?? 1
4636
+ });
3850
4637
  }
3851
4638
  };
3852
4639
  return /* @__PURE__ */ jsxs28("section", { "aria-label": "Find the hotspot", "data-lk-check-id": checkId, "data-testid": "find-hotspot", children: [
3853
4640
  /* @__PURE__ */ jsxs28("div", { style: { position: "relative", display: "inline-block" }, children: [
3854
- /* @__PURE__ */ jsx31("img", { src: props.src, alt: props.alt, style: { maxWidth: "100%" } }),
4641
+ resolvedSrc ? /* @__PURE__ */ jsx31("img", { src: resolvedSrc, alt: props.alt, style: { maxWidth: "100%" } }) : /* @__PURE__ */ jsx31("p", { role: "alert", children: "This image URL is not allowed." }),
3855
4642
  props.targets.map((t) => /* @__PURE__ */ jsx31(
3856
4643
  "button",
3857
4644
  {
@@ -3882,14 +4669,26 @@ var FindHotspot = forwardRef15(function FindHotspot2(props, ref) {
3882
4669
  setLessonkitBlockType(FindHotspot, "FindHotspot");
3883
4670
 
3884
4671
  // src/blocks/FindMultipleHotspots.tsx
3885
- import { forwardRef as forwardRef16, useMemo as useMemo21, useState as useState25 } from "react";
4672
+ import { forwardRef as forwardRef16, useEffect as useEffect26, useMemo as useMemo21, useState as useState25 } from "react";
3886
4673
  import { jsx as jsx32, jsxs as jsxs29 } from "react/jsx-runtime";
3887
4674
  var INTERACTION12 = "findMultipleHotspots";
3888
4675
  function FindMultipleHotspotsInner(props, ref) {
3889
4676
  const checkId = useMemo21(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
4677
+ const { config } = useLessonkit();
4678
+ const resolvedSrc = resolveMediaSrc(props.src, { allowedHosts: config.embed?.allowedHosts });
3890
4679
  const [selected, setSelected] = useState25(/* @__PURE__ */ new Set());
3891
4680
  const [checked, setChecked] = useState25(false);
3892
4681
  const assessment = useAssessmentState(props.enclosingLessonId);
4682
+ const correctSet = useMemo21(
4683
+ () => new Set(props.correctTargetIds),
4684
+ [props.correctTargetIds]
4685
+ );
4686
+ const targetIdsKey = props.targets.map((t) => t.id).join("\0");
4687
+ const correctIdsKey = props.correctTargetIds.join("\0");
4688
+ useEffect26(() => {
4689
+ setSelected(/* @__PURE__ */ new Set());
4690
+ setChecked(false);
4691
+ }, [checkId, correctIdsKey, targetIdsKey]);
3893
4692
  const toggle = (id) => {
3894
4693
  setSelected((prev) => {
3895
4694
  const next = new Set(prev);
@@ -3899,13 +4698,26 @@ function FindMultipleHotspotsInner(props, ref) {
3899
4698
  });
3900
4699
  setChecked(false);
3901
4700
  };
3902
- const correct = selected.size === props.correctTargetIds.length && props.correctTargetIds.every((id) => selected.has(id));
4701
+ const maxScore = props.correctTargetIds.length || 1;
4702
+ const score = props.correctTargetIds.filter((id) => selected.has(id)).length;
4703
+ const wrongSelected = [...selected].filter((id) => !correctSet.has(id)).length;
4704
+ const passedThreshold = meetsPassingThreshold(score, maxScore, props.passingScore) && wrongSelected === 0;
4705
+ const isFactuallyCorrect = (sel) => {
4706
+ const wrong = [...sel].filter((id) => !correctSet.has(id)).length;
4707
+ const matched = props.correctTargetIds.filter((id) => sel.has(id)).length;
4708
+ return wrong === 0 && sel.size === props.correctTargetIds.length && matched === maxScore;
4709
+ };
4710
+ const factualCorrect = checked ? isFactuallyCorrect(selected) : false;
4711
+ const validTargetIds = useMemo21(
4712
+ () => new Set(props.targets.map((t) => t.id)),
4713
+ [props.targets]
4714
+ );
3903
4715
  const handle = useMemo21(
3904
4716
  () => buildAssessmentHandle({
3905
4717
  checkId,
3906
- getScore: () => checked && correct ? 1 : 0,
3907
- getMaxScore: () => 1,
3908
- getAnswerGiven: () => selected.size > 0,
4718
+ getScore: () => score,
4719
+ getMaxScore: () => maxScore,
4720
+ getAnswerGiven: () => checked,
3909
4721
  resetTask: () => {
3910
4722
  setSelected(/* @__PURE__ */ new Set());
3911
4723
  setChecked(false);
@@ -3915,42 +4727,68 @@ function FindMultipleHotspotsInner(props, ref) {
3915
4727
  checkId,
3916
4728
  interactionType: INTERACTION12,
3917
4729
  response: [...selected],
3918
- correct: checked ? correct : void 0,
3919
- score: checked && correct ? 1 : 0,
3920
- maxScore: 1
4730
+ correct: checked ? factualCorrect : void 0,
4731
+ score: checked ? score : 0,
4732
+ maxScore
3921
4733
  }),
3922
4734
  getCurrentState: () => ({ selected: [...selected], checked }),
3923
4735
  resume: (state) => {
3924
4736
  const raw = state.selected;
3925
- if (Array.isArray(raw)) setSelected(new Set(raw.filter((id) => typeof id === "string")));
4737
+ if (Array.isArray(raw)) {
4738
+ setSelected(
4739
+ new Set(
4740
+ raw.filter(
4741
+ (id) => typeof id === "string" && validTargetIds.has(id)
4742
+ )
4743
+ )
4744
+ );
4745
+ }
3926
4746
  readBooleanStateField(state, "checked", setChecked);
3927
4747
  }
3928
4748
  }),
3929
- [checkId, selected, checked, correct, props.correctTargetIds]
4749
+ [
4750
+ checkId,
4751
+ checked,
4752
+ factualCorrect,
4753
+ maxScore,
4754
+ props.correctTargetIds,
4755
+ score,
4756
+ selected,
4757
+ validTargetIds
4758
+ ]
3930
4759
  );
3931
4760
  useAssessmentHandleRegistration(checkId, handle, ref);
3932
4761
  const submit = () => {
3933
4762
  if (selected.size === 0 || checked) return;
4763
+ const correctAtSubmit = isFactuallyCorrect(selected);
3934
4764
  setChecked(true);
3935
4765
  assessment.answer({
3936
4766
  checkId,
3937
4767
  interactionType: INTERACTION12,
3938
4768
  response: [...selected],
3939
- correct
4769
+ correct: correctAtSubmit
3940
4770
  });
3941
- if (correct) {
4771
+ if (passedThreshold) {
3942
4772
  assessment.complete({
3943
4773
  checkId,
3944
4774
  interactionType: INTERACTION12,
3945
- score: 1,
3946
- maxScore: 1,
3947
- passingScore: props.passingScore ?? 1
4775
+ score,
4776
+ maxScore,
4777
+ passingScore: props.passingScore ?? maxScore
4778
+ });
4779
+ } else if (props.enableRetry === false) {
4780
+ assessment.complete({
4781
+ checkId,
4782
+ interactionType: INTERACTION12,
4783
+ score,
4784
+ maxScore,
4785
+ passingScore: props.passingScore ?? maxScore
3948
4786
  });
3949
4787
  }
3950
4788
  };
3951
4789
  return /* @__PURE__ */ jsxs29("section", { "aria-label": "Find multiple hotspots", "data-lk-check-id": checkId, "data-testid": "find-multiple-hotspots", children: [
3952
4790
  /* @__PURE__ */ jsxs29("div", { style: { position: "relative", display: "inline-block" }, children: [
3953
- /* @__PURE__ */ jsx32("img", { src: props.src, alt: props.alt, style: { maxWidth: "100%" } }),
4791
+ resolvedSrc ? /* @__PURE__ */ jsx32("img", { src: resolvedSrc, alt: props.alt, style: { maxWidth: "100%" } }) : /* @__PURE__ */ jsx32("p", { role: "alert", children: "This image URL is not allowed." }),
3954
4792
  props.targets.map((t) => /* @__PURE__ */ jsx32(
3955
4793
  "button",
3956
4794
  {
@@ -3971,7 +4809,7 @@ function FindMultipleHotspotsInner(props, ref) {
3971
4809
  ))
3972
4810
  ] }),
3973
4811
  /* @__PURE__ */ jsx32("button", { type: "button", "data-testid": "check-hotspots", disabled: selected.size === 0, onClick: submit, children: "Check" }),
3974
- checked ? /* @__PURE__ */ jsx32("p", { role: "status", children: correct ? "Correct" : "Try again" }) : null
4812
+ checked ? /* @__PURE__ */ jsx32("p", { role: "status", children: passedThreshold ? "Correct" : "Try again" }) : null
3975
4813
  ] });
3976
4814
  }
3977
4815
  var FindMultipleHotspotsInnerForwarded = forwardRef16(FindMultipleHotspotsInner);
@@ -3982,6 +4820,679 @@ var FindMultipleHotspots = forwardRef16(
3982
4820
  );
3983
4821
  setLessonkitBlockType(FindMultipleHotspots, "FindMultipleHotspots");
3984
4822
 
4823
+ // src/compound/useBranchingScenario.tsx
4824
+ import { createContext, useContext as useContext3 } from "react";
4825
+ import { jsx as jsx33 } from "react/jsx-runtime";
4826
+ var BranchingScenarioContext = createContext(null);
4827
+ function BranchingScenarioProvider(props) {
4828
+ return /* @__PURE__ */ jsx33(BranchingScenarioContext.Provider, { value: props.value, children: props.children });
4829
+ }
4830
+ function useBranchingScenario() {
4831
+ const ctx = useContext3(BranchingScenarioContext);
4832
+ if (!ctx) {
4833
+ throw new Error("useBranchingScenario must be used within BranchingScenario");
4834
+ }
4835
+ return ctx;
4836
+ }
4837
+ function useBranchingScenarioOptional() {
4838
+ return useContext3(BranchingScenarioContext);
4839
+ }
4840
+
4841
+ // src/blocks/BranchingScenario.tsx
4842
+ import React34, { forwardRef as forwardRef17, useCallback as useCallback11, useEffect as useEffect27, useLayoutEffect as useLayoutEffect2, useMemo as useMemo22, useRef as useRef20, useState as useState26 } from "react";
4843
+ import { clampCompoundPageIndex as clampCompoundPageIndex3 } from "@lessonkit/core";
4844
+
4845
+ // src/compound/useCompoundBranchHandle.ts
4846
+ import { useCallback as useCallback10, useImperativeHandle } from "react";
4847
+ import { createCompoundResumeState as createCompoundResumeState4 } from "@lessonkit/core";
4848
+ function useCompoundBranchHandle(ref, opts) {
4849
+ const bridgeRef = useCompoundHydrationBridgeRef();
4850
+ const {
4851
+ activePageIndex,
4852
+ getRegisteredHandles,
4853
+ visitedNodeIndices,
4854
+ choiceScores,
4855
+ meta,
4856
+ maxChoiceScore = 0,
4857
+ onResetMeta,
4858
+ enableSolutionsButton
4859
+ } = opts;
4860
+ const filterVisited = useCallback10(
4861
+ (handles) => {
4862
+ const filtered = [];
4863
+ for (const entry of handles) {
4864
+ const { pageIndex } = entry;
4865
+ if (pageIndex === void 0) continue;
4866
+ if (visitedNodeIndices.has(pageIndex)) filtered.push(entry);
4867
+ }
4868
+ return filtered;
4869
+ },
4870
+ [visitedNodeIndices]
4871
+ );
4872
+ useImperativeHandle(
4873
+ ref,
4874
+ () => ({
4875
+ getScore: () => {
4876
+ const assessment = aggregateAssessmentScores(filterVisited(getRegisteredHandles().values()));
4877
+ return assessment.score + sumChoiceScores(choiceScores);
4878
+ },
4879
+ getMaxScore: () => {
4880
+ const assessment = aggregateAssessmentScores(filterVisited(getRegisteredHandles().values()));
4881
+ return assessment.maxScore + maxChoiceScore;
4882
+ },
4883
+ getAnswerGiven: () => aggregateAssessmentScores(filterVisited(getRegisteredHandles().values())).allAnswered,
4884
+ resetTask: () => {
4885
+ onResetMeta();
4886
+ for (const entry of filterVisited(getRegisteredHandles().values())) {
4887
+ entry.handle.resetTask();
4888
+ }
4889
+ },
4890
+ showSolutions: () => {
4891
+ if (!enableSolutionsButton) return;
4892
+ for (const entry of filterVisited(getRegisteredHandles().values())) {
4893
+ entry.handle.showSolutions();
4894
+ }
4895
+ },
4896
+ getCurrentState: () => {
4897
+ const childStates = {};
4898
+ for (const [checkId, entry] of getRegisteredHandles()) {
4899
+ const { pageIndex } = entry;
4900
+ if (pageIndex === void 0 || !visitedNodeIndices.has(pageIndex)) continue;
4901
+ if (entry.handle.getCurrentState) {
4902
+ childStates[checkId] = entry.handle.getCurrentState();
4903
+ }
4904
+ }
4905
+ return mergeBranchMetaIntoState(
4906
+ createCompoundResumeState4({ activePageIndex, childStates }),
4907
+ meta
4908
+ );
4909
+ },
4910
+ resume: (state) => {
4911
+ bridgeRef?.current?.notifyImperativeResume(state);
4912
+ }
4913
+ }),
4914
+ [
4915
+ activePageIndex,
4916
+ bridgeRef,
4917
+ choiceScores,
4918
+ enableSolutionsButton,
4919
+ filterVisited,
4920
+ getRegisteredHandles,
4921
+ meta,
4922
+ maxChoiceScore,
4923
+ onResetMeta,
4924
+ visitedNodeIndices
4925
+ ]
4926
+ );
4927
+ }
4928
+
4929
+ // src/compound/validateBranchGraph.ts
4930
+ import React33 from "react";
4931
+ import { validateBranchGraph } from "@lessonkit/core";
4932
+ function extractBranchGraph(nodes) {
4933
+ return nodes.map((node) => {
4934
+ const choices = [];
4935
+ React33.Children.forEach(node.props.children, (child) => {
4936
+ if (!React33.isValidElement(child)) return;
4937
+ if (getLessonkitBlockType(child.type) !== "BranchChoice") return;
4938
+ const targetNodeId = child.props.targetNodeId;
4939
+ if (typeof targetNodeId === "string") {
4940
+ choices.push({ targetNodeId: normalizeComponentId(targetNodeId, "blockId") });
4941
+ }
4942
+ });
4943
+ return {
4944
+ nodeId: normalizeComponentId(node.props.nodeId, "blockId"),
4945
+ terminal: Boolean(node.props.terminal),
4946
+ choices
4947
+ };
4948
+ });
4949
+ }
4950
+ function validateBranchGraphAtMount(startNodeId, nodes, strict) {
4951
+ const graph = extractBranchGraph(nodes);
4952
+ const result = validateBranchGraph(startNodeId, graph.map(({ nodeId, choices }) => ({ nodeId, choices })));
4953
+ for (const node of graph) {
4954
+ if (node.terminal && node.choices.length > 0) {
4955
+ const msg = `[lessonkit] BranchingScenario: terminal node "${node.nodeId}" must not contain BranchChoice children`;
4956
+ if (strict || !isDevEnvironment()) {
4957
+ throw new Error(msg);
4958
+ }
4959
+ console.warn(msg);
4960
+ }
4961
+ }
4962
+ for (const issue of result.issues) {
4963
+ const msg = `[lessonkit] BranchingScenario: ${issue.message}`;
4964
+ if (strict || !isDevEnvironment()) {
4965
+ throw new Error(msg);
4966
+ }
4967
+ console.warn(msg);
4968
+ }
4969
+ }
4970
+ function buildNodeIndexMap(nodes) {
4971
+ const map = /* @__PURE__ */ new Map();
4972
+ nodes.forEach((node, index) => {
4973
+ map.set(normalizeComponentId(node.props.nodeId, "blockId"), index);
4974
+ });
4975
+ return map;
4976
+ }
4977
+ function buildNodeLabels(nodes) {
4978
+ const map = /* @__PURE__ */ new Map();
4979
+ for (const node of nodes) {
4980
+ const nodeId = normalizeComponentId(node.props.nodeId, "blockId");
4981
+ map.set(nodeId, node.props.title ?? nodeId);
4982
+ }
4983
+ return map;
4984
+ }
4985
+ function filterBranchNodeContent(children) {
4986
+ const filtered = [];
4987
+ React33.Children.forEach(children, (child) => {
4988
+ if (!React33.isValidElement(child)) {
4989
+ filtered.push(child);
4990
+ return;
4991
+ }
4992
+ if (getLessonkitBlockType(child.type) === "BranchChoice") return;
4993
+ filtered.push(child);
4994
+ });
4995
+ return filtered;
4996
+ }
4997
+ function nodeHasChoices(node) {
4998
+ let hasChoice = false;
4999
+ React33.Children.forEach(node.props.children, (child) => {
5000
+ if (!React33.isValidElement(child)) return;
5001
+ if (getLessonkitBlockType(child.type) === "BranchChoice") hasChoice = true;
5002
+ });
5003
+ return hasChoice;
5004
+ }
5005
+
5006
+ // src/blocks/BranchingScenario.tsx
5007
+ import { jsx as jsx34, jsxs as jsxs30 } from "react/jsx-runtime";
5008
+ var BranchingScenarioInner = forwardRef17(function BranchingScenarioInner2(props, ref) {
5009
+ const { blockId, nodes, persistEnabled, startNodeId } = props;
5010
+ validateCompoundChildren("BranchingScenario", nodes);
5011
+ useLayoutEffect2(() => {
5012
+ validateBranchGraphAtMount(startNodeId, nodes);
5013
+ }, [startNodeId, nodes]);
5014
+ const { config, track, storage } = useLessonkit();
5015
+ const lessonId = useEnclosingLessonId();
5016
+ const ctx = useCompoundRegistry();
5017
+ const nodeIndexMap = useMemo22(() => buildNodeIndexMap(nodes), [nodes]);
5018
+ const nodeLabels = useMemo22(() => buildNodeLabels(nodes), [nodes]);
5019
+ const [meta, setMeta] = useState26(() => createInitialBranchMeta(startNodeId));
5020
+ const metaRef = useRef20(meta);
5021
+ const branchViewedRef = useRef20(/* @__PURE__ */ new Set());
5022
+ const legacyResumeWarnedRef = useRef20(false);
5023
+ const commitMeta = useCallback11((next) => {
5024
+ metaRef.current = next;
5025
+ setMeta(next);
5026
+ }, []);
5027
+ const activeIndex = nodeIndexMap.get(meta.activeNodeId) ?? 0;
5028
+ const visitedNodeIndices = useMemo22(() => {
5029
+ const indices = /* @__PURE__ */ new Set();
5030
+ for (const nodeId of meta.visitedNodeIds) {
5031
+ const i = nodeIndexMap.get(nodeId);
5032
+ if (i !== void 0) indices.add(i);
5033
+ }
5034
+ return indices;
5035
+ }, [meta.visitedNodeIds, nodeIndexMap]);
5036
+ const syncBranchViewedRef = useCallback11(
5037
+ (restoredMeta) => {
5038
+ const next = /* @__PURE__ */ new Set();
5039
+ for (const nodeId of restoredMeta.visitedNodeIds) {
5040
+ if (nodeId !== restoredMeta.activeNodeId) {
5041
+ next.add(`${blockId}:${nodeId}`);
5042
+ }
5043
+ }
5044
+ branchViewedRef.current = next;
5045
+ },
5046
+ [blockId]
5047
+ );
5048
+ const applyResumeState = useCallback11(
5049
+ (state) => {
5050
+ const fromMeta = readBranchingScenarioMeta(state.childStates);
5051
+ if (fromMeta) {
5052
+ const sanitized = sanitizeBranchMeta(fromMeta, nodeIndexMap, startNodeId);
5053
+ commitMeta(sanitized);
5054
+ syncBranchViewedRef(sanitized);
5055
+ return;
5056
+ }
5057
+ const hasChildCheckStates = Object.keys(state.childStates).some((k) => k !== BS_META_KEY);
5058
+ const clampedIndex = clampCompoundPageIndex3(state.activePageIndex, nodes.length);
5059
+ const nodeAtIndex = nodes[clampedIndex];
5060
+ if (nodeAtIndex || hasChildCheckStates) {
5061
+ const nodeId = nodeAtIndex?.props.nodeId ?? startNodeId;
5062
+ const visitedNodeIds = [startNodeId];
5063
+ if (nodeId !== startNodeId) visitedNodeIds.push(nodeId);
5064
+ const legacyMeta = sanitizeBranchMeta(
5065
+ { activeNodeId: nodeId, visitedNodeIds, choiceScores: {} },
5066
+ nodeIndexMap,
5067
+ startNodeId
5068
+ );
5069
+ commitMeta(legacyMeta);
5070
+ syncBranchViewedRef(legacyMeta);
5071
+ if (!legacyResumeWarnedRef.current && isDevEnvironment() && (hasChildCheckStates || state.activePageIndex !== 0)) {
5072
+ legacyResumeWarnedRef.current = true;
5073
+ console.warn(
5074
+ "[lessonkit] BranchingScenario: legacy save without branch meta; restored via activePageIndex and child states"
5075
+ );
5076
+ }
5077
+ return;
5078
+ }
5079
+ if (!legacyResumeWarnedRef.current && isDevEnvironment() && (state.activePageIndex !== 0 || Object.keys(state.childStates).length > 0)) {
5080
+ legacyResumeWarnedRef.current = true;
5081
+ console.warn(
5082
+ "[lessonkit] BranchingScenario: legacy save without branch meta; starting at startNodeId"
5083
+ );
5084
+ }
5085
+ const fresh = sanitizeBranchMeta(createInitialBranchMeta(startNodeId), nodeIndexMap, startNodeId);
5086
+ commitMeta(fresh);
5087
+ syncBranchViewedRef(fresh);
5088
+ },
5089
+ [commitMeta, nodeIndexMap, nodes, startNodeId, syncBranchViewedRef]
5090
+ );
5091
+ const resetBranchMeta = useCallback11(() => {
5092
+ commitMeta(createInitialBranchMeta(startNodeId));
5093
+ branchViewedRef.current = /* @__PURE__ */ new Set();
5094
+ }, [commitMeta, startNodeId]);
5095
+ const transformState = useCallback11(
5096
+ (state) => mergeBranchMetaIntoState(state, metaRef.current),
5097
+ []
5098
+ );
5099
+ const shouldIncludeChildState = useCallback11(
5100
+ (_checkId, pageIndex) => pageIndex !== void 0 && visitedNodeIndices.has(pageIndex),
5101
+ [visitedNodeIndices]
5102
+ );
5103
+ useCompoundPersistence({
5104
+ courseId: config.courseId,
5105
+ compoundId: blockId,
5106
+ pageCount: nodes.length,
5107
+ index: activeIndex,
5108
+ setIndex: () => {
5109
+ },
5110
+ enabled: persistEnabled,
5111
+ storage,
5112
+ transformState,
5113
+ onCompoundResume: applyResumeState,
5114
+ shouldIncludeChildState
5115
+ });
5116
+ const maxChoiceScoreOnPath = useMemo22(() => {
5117
+ if (!meta.choiceScores) return 0;
5118
+ const branchFromIds = /* @__PURE__ */ new Set();
5119
+ for (const key of Object.keys(meta.choiceScores)) {
5120
+ const fromId = key.split(":")[0];
5121
+ if (fromId) branchFromIds.add(fromId);
5122
+ }
5123
+ let total = 0;
5124
+ for (const node of nodes) {
5125
+ const nodeId = node.props.nodeId;
5126
+ if (!branchFromIds.has(nodeId)) continue;
5127
+ let maxWeight = 0;
5128
+ let found = false;
5129
+ for (const child of React34.Children.toArray(node.props.children)) {
5130
+ if (!React34.isValidElement(child)) continue;
5131
+ const weight = child.props.scoreWeight;
5132
+ if (typeof weight === "number" && Number.isFinite(weight)) {
5133
+ found = true;
5134
+ maxWeight = Math.max(maxWeight, weight);
5135
+ }
5136
+ }
5137
+ if (found) total += maxWeight;
5138
+ }
5139
+ return total;
5140
+ }, [meta.choiceScores, nodes]);
5141
+ useCompoundBranchHandle(ref, {
5142
+ activePageIndex: activeIndex,
5143
+ getRegisteredHandles: () => ctx?.getRegisteredHandles() ?? /* @__PURE__ */ new Map(),
5144
+ visitedNodeIndices,
5145
+ choiceScores: meta.choiceScores ?? {},
5146
+ meta,
5147
+ maxChoiceScore: maxChoiceScoreOnPath,
5148
+ onResetMeta: resetBranchMeta,
5149
+ enableSolutionsButton: props.enableSolutionsButton
5150
+ });
5151
+ const activeNode = nodes[activeIndex];
5152
+ const isTerminal = Boolean(activeNode?.props.terminal) || (activeNode ? !nodeHasChoices(activeNode) && meta.activeNodeId !== startNodeId : false);
5153
+ const visitedLabels = useMemo22(
5154
+ () => meta.visitedNodeIds.map((id) => ({
5155
+ nodeId: id,
5156
+ label: nodeLabels.get(id) ?? id
5157
+ })),
5158
+ [meta.visitedNodeIds, nodeLabels]
5159
+ );
5160
+ useEffect27(() => {
5161
+ if (!lessonId || !activeNode) return;
5162
+ const dedupeKey = `${blockId}:${meta.activeNodeId}`;
5163
+ if (branchViewedRef.current.has(dedupeKey)) return;
5164
+ branchViewedRef.current.add(dedupeKey);
5165
+ track(
5166
+ "branch_node_viewed",
5167
+ {
5168
+ blockId,
5169
+ nodeId: meta.activeNodeId,
5170
+ nodeIndex: activeIndex,
5171
+ nodeTitle: activeNode.props.title
5172
+ },
5173
+ { lessonId }
5174
+ );
5175
+ }, [activeIndex, activeNode, blockId, lessonId, meta.activeNodeId, track]);
5176
+ const navigateToNode = useCallback11(
5177
+ (opts) => {
5178
+ const toNodeId = normalizeComponentId(opts.toNodeId, "blockId");
5179
+ const fromNodeId = normalizeComponentId(opts.fromNodeId, "blockId");
5180
+ if (!nodeIndexMap.has(toNodeId)) {
5181
+ if (isDevEnvironment()) {
5182
+ console.warn(
5183
+ `[lessonkit] BranchingScenario: unknown targetNodeId "${toNodeId}" from "${fromNodeId}"`
5184
+ );
5185
+ }
5186
+ return;
5187
+ }
5188
+ const activeNodeId = metaRef.current.activeNodeId;
5189
+ if (fromNodeId !== activeNodeId) {
5190
+ if (isDevEnvironment()) {
5191
+ console.warn(
5192
+ `[lessonkit] BranchingScenario: navigateToNode from "${fromNodeId}" but active node is "${activeNodeId}"`
5193
+ );
5194
+ }
5195
+ return;
5196
+ }
5197
+ if (lessonId) {
5198
+ track(
5199
+ "branch_selected",
5200
+ {
5201
+ blockId,
5202
+ fromNodeId,
5203
+ toNodeId,
5204
+ label: opts.label,
5205
+ scoreWeight: opts.scoreWeight
5206
+ },
5207
+ { lessonId }
5208
+ );
5209
+ }
5210
+ setMeta((prev) => {
5211
+ const choiceScores = applyChoiceScoreUpdate(
5212
+ prev.choiceScores,
5213
+ fromNodeId,
5214
+ toNodeId,
5215
+ opts.scoreWeight
5216
+ );
5217
+ const visited = prev.visitedNodeIds.includes(toNodeId) ? prev.visitedNodeIds : [...prev.visitedNodeIds, toNodeId];
5218
+ const next = sanitizeBranchMeta(
5219
+ {
5220
+ activeNodeId: toNodeId,
5221
+ visitedNodeIds: visited,
5222
+ choiceScores
5223
+ },
5224
+ nodeIndexMap,
5225
+ startNodeId
5226
+ );
5227
+ metaRef.current = next;
5228
+ return next;
5229
+ });
5230
+ },
5231
+ [blockId, lessonId, nodeIndexMap, startNodeId, track]
5232
+ );
5233
+ const choicesLocked = isTerminal;
5234
+ const contextValue = useMemo22(
5235
+ () => ({
5236
+ compoundBlockId: blockId,
5237
+ activeNodeId: meta.activeNodeId,
5238
+ visitedNodeIds: meta.visitedNodeIds,
5239
+ visitedLabels: visitedLabels.map((entry) => entry.label),
5240
+ navigateToNode,
5241
+ isTerminal,
5242
+ choicesLocked
5243
+ }),
5244
+ [blockId, choicesLocked, isTerminal, meta.activeNodeId, meta.visitedNodeIds, navigateToNode, visitedLabels]
5245
+ );
5246
+ 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;
5247
+ 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;
5248
+ return /* @__PURE__ */ jsx34(BranchingScenarioProvider, { value: contextValue, children: /* @__PURE__ */ jsxs30("section", { "aria-label": props.title, "data-testid": "branching-scenario", "data-lk-block-id": blockId, children: [
5249
+ /* @__PURE__ */ jsx34("h3", { children: props.title }),
5250
+ props.showPathScore && ctx ? /* @__PURE__ */ jsxs30("p", { "data-testid": "branch-score", children: [
5251
+ "Score: ",
5252
+ pathScore,
5253
+ " / ",
5254
+ pathMaxScore
5255
+ ] }) : null,
5256
+ props.showPathRecap && isTerminal && meta.visitedNodeIds.length > 0 ? /* @__PURE__ */ jsxs30("aside", { "data-testid": "branch-path-recap", "aria-label": "Your path", children: [
5257
+ /* @__PURE__ */ jsx34("h4", { children: "Your path" }),
5258
+ /* @__PURE__ */ jsx34("ol", { children: visitedLabels.map((entry) => /* @__PURE__ */ jsx34("li", { children: entry.label }, entry.nodeId)) })
5259
+ ] }) : null,
5260
+ /* @__PURE__ */ jsx34("div", { "data-testid": "branching-scenario-active-node", children: nodes.map((node, i) => {
5261
+ const content = React34.Children.map(node.props.children, (child) => {
5262
+ if (!React34.isValidElement(child)) return child;
5263
+ if (getLessonkitBlockType(child.type) !== "BranchChoice") return child;
5264
+ return React34.cloneElement(child, {
5265
+ fromNodeId: node.props.nodeId
5266
+ });
5267
+ });
5268
+ return React34.cloneElement(node, {
5269
+ key: node.key ?? node.props.nodeId,
5270
+ hidden: i !== activeIndex,
5271
+ nodeIndex: i,
5272
+ children: content
5273
+ });
5274
+ }) })
5275
+ ] }) });
5276
+ });
5277
+ var BranchingScenario = forwardRef17(
5278
+ function BranchingScenario2(props, ref) {
5279
+ const nodes = React34.Children.toArray(props.children).filter(
5280
+ React34.isValidElement
5281
+ );
5282
+ const { config } = useLessonkit();
5283
+ const persistEnabled = config.session?.persistCompoundState !== false;
5284
+ requireCompoundBlockIdWhenPersisting({
5285
+ persistEnabled,
5286
+ blockId: props.blockId,
5287
+ componentName: "BranchingScenario"
5288
+ });
5289
+ const blockId = useMemo22(
5290
+ () => normalizeComponentId(props.blockId, "blockId"),
5291
+ [props.blockId]
5292
+ );
5293
+ const startNodeId = useMemo22(
5294
+ () => normalizeComponentId(props.startNodeId, "blockId"),
5295
+ [props.startNodeId]
5296
+ );
5297
+ const nodeIndexMap = useMemo22(() => buildNodeIndexMap(nodes), [nodes]);
5298
+ const initialIndex = nodeIndexMap.get(startNodeId) ?? 0;
5299
+ const hydrationKey = `${config.courseId ?? "no-course"}:${blockId}`;
5300
+ const setIndexStable = useCallback11(() => {
5301
+ }, []);
5302
+ return /* @__PURE__ */ jsx34(CompoundProvider, { activePageIndex: initialIndex, onActivePageIndexChange: setIndexStable, children: /* @__PURE__ */ jsx34(
5303
+ BranchingScenarioInner,
5304
+ {
5305
+ ...props,
5306
+ startNodeId,
5307
+ ref,
5308
+ blockId,
5309
+ nodes,
5310
+ persistEnabled
5311
+ },
5312
+ hydrationKey
5313
+ ) });
5314
+ }
5315
+ );
5316
+ setLessonkitBlockType(BranchingScenario, "BranchingScenario");
5317
+
5318
+ // src/blocks/BranchNode.tsx
5319
+ import "react";
5320
+ import { jsx as jsx35, jsxs as jsxs31 } from "react/jsx-runtime";
5321
+ function BranchNode(props) {
5322
+ validateCompoundChildren("BranchNode", filterBranchNodeContent(props.children));
5323
+ return /* @__PURE__ */ jsxs31(
5324
+ "section",
5325
+ {
5326
+ "aria-label": props.title ?? props.nodeId,
5327
+ "data-lk-node-id": props.nodeId,
5328
+ "data-testid": `branch-node-${props.nodeId}`,
5329
+ hidden: props.hidden ? true : void 0,
5330
+ style: props.hidden ? { display: "none" } : void 0,
5331
+ children: [
5332
+ props.title ? /* @__PURE__ */ jsx35("h4", { children: props.title }) : null,
5333
+ /* @__PURE__ */ jsx35(CompoundPageIndexProvider, { pageIndex: props.nodeIndex ?? 0, children: /* @__PURE__ */ jsx35("div", { children: props.children }) })
5334
+ ]
5335
+ }
5336
+ );
5337
+ }
5338
+ setLessonkitBlockType(BranchNode, "BranchNode");
5339
+
5340
+ // src/blocks/BranchChoice.tsx
5341
+ import { useId as useId4 } from "react";
5342
+ import { jsx as jsx36 } from "react/jsx-runtime";
5343
+ function BranchChoice(props) {
5344
+ const ctx = useBranchingScenarioOptional();
5345
+ const groupId = useId4();
5346
+ const fromNodeId = props.fromNodeId ?? ctx?.activeNodeId ?? "";
5347
+ const isActiveNode = ctx ? ctx.activeNodeId === fromNodeId : true;
5348
+ const locked = ctx?.choicesLocked ?? false;
5349
+ const onSelect = () => {
5350
+ if (!ctx || !fromNodeId || props.disabled || locked || !isActiveNode) return;
5351
+ ctx.navigateToNode({
5352
+ fromNodeId,
5353
+ toNodeId: props.targetNodeId,
5354
+ label: props.label,
5355
+ scoreWeight: props.scoreWeight
5356
+ });
5357
+ };
5358
+ return /* @__PURE__ */ jsx36(
5359
+ "button",
5360
+ {
5361
+ type: "button",
5362
+ role: "radio",
5363
+ "aria-checked": false,
5364
+ "aria-labelledby": groupId,
5365
+ "data-testid": `branch-choice-${props.targetNodeId}`,
5366
+ disabled: props.disabled || locked || !isActiveNode,
5367
+ onClick: onSelect,
5368
+ children: /* @__PURE__ */ jsx36("span", { id: groupId, children: props.label })
5369
+ }
5370
+ );
5371
+ }
5372
+ setLessonkitBlockType(BranchChoice, "BranchChoice");
5373
+
5374
+ // src/blocks/Embed.tsx
5375
+ import { useEffect as useEffect28 } from "react";
5376
+ import { jsx as jsx37, jsxs as jsxs32 } from "react/jsx-runtime";
5377
+ function Embed(props) {
5378
+ const blockId = normalizeComponentId(props.blockId, "blockId");
5379
+ const { config, track } = useLessonkit();
5380
+ const lessonId = useEnclosingLessonId();
5381
+ const resolvedSrc = resolveEmbedSrc(props.src, {
5382
+ allowedHosts: config.embed?.allowedHosts
5383
+ });
5384
+ const sandbox = buildEmbedSandbox(props.allow, {
5385
+ restrictPopupsInProduction: config.embed?.restrictPopupsInProduction ?? true
5386
+ });
5387
+ const aspectRatio = resolveEmbedAspectRatio(props.aspectRatio);
5388
+ useEffect28(() => {
5389
+ if (!resolvedSrc) return;
5390
+ track(
5391
+ "interaction",
5392
+ { kind: "embed_viewed", blockId, src: telemetryEmbedSrc(resolvedSrc) },
5393
+ lessonId ? { lessonId } : void 0
5394
+ );
5395
+ }, [blockId, lessonId, resolvedSrc, track]);
5396
+ if (!resolvedSrc) {
5397
+ return /* @__PURE__ */ jsx37("figure", { "data-lk-block-id": blockId, "data-testid": `embed-${blockId}`, children: /* @__PURE__ */ jsx37("p", { role: "alert", children: "This embed URL is not allowed." }) });
5398
+ }
5399
+ return /* @__PURE__ */ jsxs32("figure", { "data-lk-block-id": blockId, "data-testid": `embed-${blockId}`, children: [
5400
+ /* @__PURE__ */ jsx37(
5401
+ "iframe",
5402
+ {
5403
+ title: props.title,
5404
+ src: resolvedSrc,
5405
+ sandbox,
5406
+ referrerPolicy: "no-referrer",
5407
+ style: aspectRatio ? { aspectRatio, width: "100%", border: 0 } : { width: "100%", border: 0 }
5408
+ }
5409
+ ),
5410
+ /* @__PURE__ */ jsx37("figcaption", { className: "lk-visually-hidden", children: props.title })
5411
+ ] });
5412
+ }
5413
+ setLessonkitBlockType(Embed, "Embed");
5414
+
5415
+ // src/blocks/Chart.tsx
5416
+ import { useEffect as useEffect29, useMemo as useMemo23 } from "react";
5417
+
5418
+ // src/blocks/chartUtils.ts
5419
+ function normalizeChartType(type) {
5420
+ if (type === "bar" || type === "pie") return type;
5421
+ if (type !== void 0 && isDevEnvironment()) {
5422
+ console.warn(`[lessonkit] Chart: unknown type "${type}"; rendering data table only.`);
5423
+ }
5424
+ return type === void 0 ? "pie" : "table";
5425
+ }
5426
+ function normalizeChartData(data) {
5427
+ if (!Array.isArray(data)) return [];
5428
+ const rows = [];
5429
+ for (let i = 0; i < data.length; i += 1) {
5430
+ const row = data[i];
5431
+ if (!row || typeof row !== "object") continue;
5432
+ const label = typeof row.label === "string" ? row.label : `Item ${i + 1}`;
5433
+ const raw = Number(row.value);
5434
+ const value = Number.isFinite(raw) && raw >= 0 ? raw : 0;
5435
+ rows.push({ label, value, key: `${label}-${i}` });
5436
+ }
5437
+ return rows;
5438
+ }
5439
+ function chartMaxValue(rows) {
5440
+ if (rows.length === 0) return 1;
5441
+ return Math.max(...rows.map((row) => row.value), 1);
5442
+ }
5443
+
5444
+ // src/blocks/Chart.tsx
5445
+ import { jsx as jsx38, jsxs as jsxs33 } from "react/jsx-runtime";
5446
+ function Chart(props) {
5447
+ const blockId = normalizeComponentId(props.blockId, "blockId");
5448
+ const { track } = useLessonkit();
5449
+ const lessonId = useEnclosingLessonId();
5450
+ const chartType = normalizeChartType(props.type);
5451
+ const rows = useMemo23(() => normalizeChartData(props.data), [props.data]);
5452
+ const max = useMemo23(() => chartMaxValue(rows), [rows]);
5453
+ useEffect29(() => {
5454
+ track(
5455
+ "interaction",
5456
+ { kind: "chart_viewed", blockId, chartType },
5457
+ lessonId ? { lessonId } : void 0
5458
+ );
5459
+ }, [blockId, chartType, lessonId, track]);
5460
+ return /* @__PURE__ */ jsxs33("figure", { "data-lk-block-id": blockId, "data-testid": `chart-${blockId}`, children: [
5461
+ props.title ? /* @__PURE__ */ jsx38("figcaption", { children: props.title }) : null,
5462
+ rows.length === 0 ? /* @__PURE__ */ jsx38("p", { "data-testid": "chart-empty", children: "No chart data." }) : chartType === "table" ? null : chartType === "bar" ? /* @__PURE__ */ jsx38("div", { role: "img", "aria-label": props.title ?? "Bar chart", "aria-describedby": `${blockId}-table`, children: rows.map((datum) => /* @__PURE__ */ jsxs33("div", { style: { display: "flex", alignItems: "center", gap: "0.5rem" }, children: [
5463
+ /* @__PURE__ */ jsx38("span", { style: { minWidth: "6rem" }, children: datum.label }),
5464
+ /* @__PURE__ */ jsx38(
5465
+ "div",
5466
+ {
5467
+ style: {
5468
+ height: "1rem",
5469
+ width: `${datum.value / max * 100}%`,
5470
+ background: "var(--lk-color-primary, #2563eb)"
5471
+ },
5472
+ "aria-hidden": true
5473
+ }
5474
+ ),
5475
+ /* @__PURE__ */ jsx38("span", { children: datum.value })
5476
+ ] }, datum.key)) }) : /* @__PURE__ */ jsx38("ul", { role: "list", "aria-label": props.title ?? "Pie chart segments", children: rows.map((datum) => /* @__PURE__ */ jsxs33("li", { children: [
5477
+ datum.label,
5478
+ ": ",
5479
+ datum.value
5480
+ ] }, datum.key)) }),
5481
+ /* @__PURE__ */ jsxs33("table", { id: `${blockId}-table`, children: [
5482
+ /* @__PURE__ */ jsx38("caption", { children: props.title ?? "Chart data" }),
5483
+ /* @__PURE__ */ jsx38("thead", { children: /* @__PURE__ */ jsxs33("tr", { children: [
5484
+ /* @__PURE__ */ jsx38("th", { scope: "col", children: "Label" }),
5485
+ /* @__PURE__ */ jsx38("th", { scope: "col", children: "Value" })
5486
+ ] }) }),
5487
+ /* @__PURE__ */ jsx38("tbody", { children: rows.map((datum) => /* @__PURE__ */ jsxs33("tr", { children: [
5488
+ /* @__PURE__ */ jsx38("th", { scope: "row", children: datum.label }),
5489
+ /* @__PURE__ */ jsx38("td", { children: datum.value })
5490
+ ] }, datum.key)) })
5491
+ ] })
5492
+ ] });
5493
+ }
5494
+ setLessonkitBlockType(Chart, "Chart");
5495
+
3985
5496
  export {
3986
5497
  TrueFalse,
3987
5498
  MarkTheWords,
@@ -4014,5 +5525,11 @@ export {
4014
5525
  ImageHotspots,
4015
5526
  ImageSlider,
4016
5527
  FindHotspot,
4017
- FindMultipleHotspots
5528
+ FindMultipleHotspots,
5529
+ useBranchingScenario,
5530
+ BranchingScenario,
5531
+ BranchNode,
5532
+ BranchChoice,
5533
+ Embed,
5534
+ Chart
4018
5535
  };