@mathscan/math-exercise-engine 1.1.17 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -392,13 +392,9 @@ const MATCHER = createToken({
392
392
  name: "MATCHER",
393
393
  pattern: /MATCHER/
394
394
  });
395
- const TOP_ROW = createToken({
396
- name: "TOP_ROW",
397
- pattern: /TOP_ROW/
398
- });
399
- const BOTTOM_ROW = createToken({
400
- name: "BOTTOM_ROW",
401
- pattern: /BOTTOM_ROW/
395
+ const GROUP = createToken({
396
+ name: "GROUP",
397
+ pattern: /GROUP/
402
398
  });
403
399
  const ITEM = createToken({
404
400
  name: "ITEM",
@@ -554,8 +550,7 @@ const allTokens = [
554
550
  ARROW,
555
551
  TABLE,
556
552
  ROW,
557
- TOP_ROW,
558
- BOTTOM_ROW,
553
+ GROUP,
559
554
  CELL,
560
555
  COUNTER,
561
556
  CLOCK,
@@ -1063,39 +1058,19 @@ class MathExerciseParser extends CstParser {
1063
1058
  this.MANY3(() => {
1064
1059
  this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1065
1060
  });
1066
- this.SUBRULE(this.pairMatcherTopSection);
1067
- this.SUBRULE(this.pairMatcherBottomSection);
1061
+ this.SUBRULE(this.pairMatcherGroupSection);
1062
+ this.SUBRULE2(this.pairMatcherGroupSection);
1068
1063
  this.SUBRULE(this.matcherAnswers);
1069
1064
  this.CONSUME(RBrace);
1070
1065
  this.MANY4(() => {
1071
1066
  this.OR4([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
1072
1067
  });
1073
1068
  });
1074
- this.pairMatcherTopSection = this.RULE("pairMatcherTopSection", () => {
1075
- this.MANY(() => {
1076
- this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1077
- });
1078
- this.CONSUME(TOP_ROW);
1079
- this.MANY2(() => {
1080
- this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1081
- });
1082
- this.CONSUME(LBrace);
1083
- this.MANY3(() => {
1084
- this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1085
- });
1086
- this.AT_LEAST_ONE(() => {
1087
- this.SUBRULE(this.matcherItem);
1088
- });
1089
- this.CONSUME(RBrace);
1090
- this.MANY4(() => {
1091
- this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
1092
- });
1093
- });
1094
- this.pairMatcherBottomSection = this.RULE("pairMatcherBottomSection", () => {
1069
+ this.pairMatcherGroupSection = this.RULE("pairMatcherGroupSection", () => {
1095
1070
  this.MANY(() => {
1096
1071
  this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1097
1072
  });
1098
- this.CONSUME(BOTTOM_ROW);
1073
+ this.CONSUME(GROUP);
1099
1074
  this.MANY2(() => {
1100
1075
  this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1101
1076
  });
@@ -2588,20 +2563,24 @@ function isMatcherBlock(value) {
2588
2563
  const PairMatcherBlockSchema = z.object({
2589
2564
  type: z.literal(AST_TYPES.PAIR_MATCHER_BLOCK),
2590
2565
  name: z.string().min(1),
2591
- /** Items in the top row — at least one required */
2592
- topItems: z.array(MatcherItemSchema).min(1),
2593
- /** Items in the bottom row — at least one required */
2594
- bottomItems: z.array(MatcherItemSchema).min(1),
2566
+ /** Row layout (top/bottom, default) or column layout (left/right). */
2567
+ layout: z.enum(["rows", "columns"]).optional(),
2568
+ /** Exactly two groups, in declaration order — neither more nor fewer. */
2569
+ groups: z.tuple([
2570
+ z.array(MatcherItemSchema).min(1),
2571
+ z.array(MatcherItemSchema).min(1)
2572
+ ]),
2595
2573
  /**
2596
- * Each answer pair is [topId, bottomId].
2574
+ * Each answer pair is [groups[0] id, groups[1] id].
2597
2575
  * At least one pair is required.
2598
2576
  */
2599
2577
  answers: z.array(z.tuple([z.string().min(1), z.string().min(1)])).min(1)
2600
2578
  }).superRefine((data, ctx) => {
2579
+ const [groupAItems, groupBItems] = data.groups;
2601
2580
  const idRegistry = /* @__PURE__ */ new Map();
2602
2581
  const sections = [
2603
- { key: "topItems", items: data.topItems },
2604
- { key: "bottomItems", items: data.bottomItems }
2582
+ { key: 0, items: groupAItems },
2583
+ { key: 1, items: groupBItems }
2605
2584
  ];
2606
2585
  for (const { key: key2, items } of sections) {
2607
2586
  for (let i = 0; i < items.length; i++) {
@@ -2611,59 +2590,59 @@ const PairMatcherBlockSchema = z.object({
2611
2590
  const isCrossSection = existing.section !== key2;
2612
2591
  ctx.addIssue({
2613
2592
  code: z.ZodIssueCode.custom,
2614
- message: isCrossSection ? `itemId "${itemId}" appears in both TOP and BOTTOM — ids must be globally unique` : `Duplicate itemId "${itemId}" in ${key2 === "topItems" ? "TOP" : "BOTTOM"} section`,
2615
- path: [key2, i, "itemId"]
2593
+ message: isCrossSection ? `itemId "${itemId}" appears in both groups — ids must be globally unique` : `Duplicate itemId "${itemId}" in group ${key2}`,
2594
+ path: ["groups", key2, i, "itemId"]
2616
2595
  });
2617
2596
  } else {
2618
2597
  idRegistry.set(itemId, { section: key2, index: i });
2619
2598
  }
2620
2599
  }
2621
2600
  }
2622
- const topIds = new Set(data.topItems.map((it) => it.itemId));
2623
- const bottomIds = new Set(data.bottomItems.map((it) => it.itemId));
2624
- const usedTopIds = /* @__PURE__ */ new Set();
2625
- const usedBottomIds = /* @__PURE__ */ new Set();
2626
- data.answers.forEach(([topId, bottomId], i) => {
2627
- if (!topIds.has(topId)) {
2601
+ const groupAIds = new Set(groupAItems.map((it) => it.itemId));
2602
+ const groupBIds = new Set(groupBItems.map((it) => it.itemId));
2603
+ const usedGroupAIds = /* @__PURE__ */ new Set();
2604
+ const usedGroupBIds = /* @__PURE__ */ new Set();
2605
+ data.answers.forEach(([aId, bId], i) => {
2606
+ if (!groupAIds.has(aId)) {
2628
2607
  ctx.addIssue({
2629
2608
  code: z.ZodIssueCode.custom,
2630
- message: `Answer pair [${topId}, ${bottomId}]: "${topId}" is not declared in TOP section`,
2609
+ message: `Answer pair [${aId}, ${bId}]: "${aId}" is not declared in groups[0]`,
2631
2610
  path: ["answers", i, 0]
2632
2611
  });
2633
2612
  }
2634
- if (!bottomIds.has(bottomId)) {
2613
+ if (!groupBIds.has(bId)) {
2635
2614
  ctx.addIssue({
2636
2615
  code: z.ZodIssueCode.custom,
2637
- message: `Answer pair [${topId}, ${bottomId}]: "${bottomId}" is not declared in BOTTOM section`,
2616
+ message: `Answer pair [${aId}, ${bId}]: "${bId}" is not declared in groups[1]`,
2638
2617
  path: ["answers", i, 1]
2639
2618
  });
2640
2619
  }
2641
- if (usedTopIds.has(topId)) {
2620
+ if (usedGroupAIds.has(aId)) {
2642
2621
  ctx.addIssue({
2643
2622
  code: z.ZodIssueCode.custom,
2644
- message: `topId "${topId}" appears in more than one answer pair`,
2623
+ message: `id "${aId}" appears in more than one answer pair`,
2645
2624
  path: ["answers", i, 0]
2646
2625
  });
2647
2626
  } else {
2648
- usedTopIds.add(topId);
2627
+ usedGroupAIds.add(aId);
2649
2628
  }
2650
- if (usedBottomIds.has(bottomId)) {
2629
+ if (usedGroupBIds.has(bId)) {
2651
2630
  ctx.addIssue({
2652
2631
  code: z.ZodIssueCode.custom,
2653
- message: `bottomId "${bottomId}" appears in more than one answer pair`,
2632
+ message: `id "${bId}" appears in more than one answer pair`,
2654
2633
  path: ["answers", i, 1]
2655
2634
  });
2656
2635
  } else {
2657
- usedBottomIds.add(bottomId);
2636
+ usedGroupBIds.add(bId);
2658
2637
  }
2659
2638
  });
2660
2639
  });
2661
- function createPairMatcherBlock(name, topItems, bottomItems, answers) {
2640
+ function createPairMatcherBlock(name, groups, answers, layout) {
2662
2641
  return PairMatcherBlockSchema.parse({
2663
2642
  type: AST_TYPES.PAIR_MATCHER_BLOCK,
2664
2643
  name,
2665
- topItems,
2666
- bottomItems,
2644
+ layout,
2645
+ groups,
2667
2646
  answers
2668
2647
  });
2669
2648
  }
@@ -2850,7 +2829,7 @@ const ExerciseDocumentSchema = z.object({
2850
2829
  } else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
2851
2830
  const pm = block;
2852
2831
  record(pm.name, ["lines", bi, "name"]);
2853
- [...pm.topItems, ...pm.bottomItems].forEach((item, ii) => {
2832
+ pm.groups.flat().forEach((item, ii) => {
2854
2833
  visitInlines(item.inlines, ["lines", bi, "items", ii, "inlines"]);
2855
2834
  });
2856
2835
  } else if (block.type === AST_TYPES.RADIO_OPTIONS) {
@@ -3063,24 +3042,14 @@ class NaturalDSLVisitor extends BaseCstVisitor {
3063
3042
  pairMatcher(ctx) {
3064
3043
  const params = this.visit(ctx.parameterList);
3065
3044
  const name = params.name;
3066
- const topItems = this.visit(ctx.pairMatcherTopSection);
3067
- const bottomItems = this.visit(ctx.pairMatcherBottomSection);
3045
+ const layout = params.layout;
3046
+ const [firstGroup, secondGroup] = ctx.pairMatcherGroupSection;
3047
+ const groupAItems = this.visit(firstGroup);
3048
+ const groupBItems = this.visit(secondGroup);
3068
3049
  const rawAnswers = this.visit(ctx.matcherAnswers);
3069
- return createPairMatcherBlock(name, topItems, bottomItems, rawAnswers);
3050
+ return createPairMatcherBlock(name, [groupAItems, groupBItems], rawAnswers, layout);
3070
3051
  }
3071
- pairMatcherTopSection(ctx) {
3072
- return this.visitMatcherItemList(ctx);
3073
- }
3074
- pairMatcherBottomSection(ctx) {
3075
- return this.visitMatcherItemList(ctx);
3076
- }
3077
- /**
3078
- * Maps `ctx.matcherItem` CST nodes to MatcherItem AST nodes.
3079
- * Shared by pairMatcherTopSection and pairMatcherBottomSection because their
3080
- * grammar rules are structurally identical — both are `matcherItem+` lists
3081
- * wrapped in a labelled section keyword.
3082
- */
3083
- visitMatcherItemList(ctx) {
3052
+ pairMatcherGroupSection(ctx) {
3084
3053
  return ctx.matcherItem ? ctx.matcherItem.map((i) => this.visit(i)) : [];
3085
3054
  }
3086
3055
  // ═══════════════════════════════════════════════════════════════════════════
@@ -3872,8 +3841,12 @@ const MatcherWidgetJSONSchema = z.object({
3872
3841
  });
3873
3842
  const PairMatcherWidgetJSONSchema = z.object({
3874
3843
  type: z.literal(JSON_TYPES.PAIR_MATCHER),
3875
- topItems: z.array(MatcherItemContentSchema).min(1),
3876
- bottomItems: z.array(MatcherItemContentSchema).min(1)
3844
+ /** Row layout (top/bottom, default) or column layout (left/right). */
3845
+ layout: z.enum(["rows", "columns"]).optional(),
3846
+ groups: z.tuple([
3847
+ z.array(MatcherItemContentSchema).min(1),
3848
+ z.array(MatcherItemContentSchema).min(1)
3849
+ ])
3877
3850
  });
3878
3851
  const VisualFractionJSONSchema = z.object({
3879
3852
  type: z.literal(JSON_TYPES.VISUAL_FRACTION),
@@ -9833,7 +9806,7 @@ function collectAllInlines(blocks) {
9833
9806
  }
9834
9807
  } else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
9835
9808
  const pm = block;
9836
- for (const item of [...pm.topItems, ...pm.bottomItems]) {
9809
+ for (const item of pm.groups.flat()) {
9837
9810
  result.push(...item.inlines);
9838
9811
  }
9839
9812
  }
@@ -9940,10 +9913,14 @@ function buildMatcherWidget(matcher, idGenerator, inlineDragIds) {
9940
9913
  return result;
9941
9914
  }
9942
9915
  function buildPairMatcherWidget(pm, idGenerator, inlineDragIds) {
9916
+ const [groupAItems, groupBItems] = pm.groups;
9943
9917
  return {
9944
9918
  type: JSON_TYPES.PAIR_MATCHER,
9945
- topItems: mapMatcherItemList(pm.topItems, idGenerator, inlineDragIds),
9946
- bottomItems: mapMatcherItemList(pm.bottomItems, idGenerator, inlineDragIds)
9919
+ ...pm.layout !== void 0 && { layout: pm.layout },
9920
+ groups: [
9921
+ mapMatcherItemList(groupAItems, idGenerator, inlineDragIds),
9922
+ mapMatcherItemList(groupBItems, idGenerator, inlineDragIds)
9923
+ ]
9947
9924
  };
9948
9925
  }
9949
9926
  function transformVerticalOperation(vop, idGenerator) {
@@ -15449,13 +15426,18 @@ function applyDropToInput(targetId, item, onChange) {
15449
15426
  function isUndoDrop(event, dragId) {
15450
15427
  return event.active.id === dragId && !event.over;
15451
15428
  }
15429
+ const LockContext = createContext(false);
15430
+ const ExerciseLockProvider = ({ locked, children }) => /* @__PURE__ */ jsx(LockContext.Provider, { value: locked, children });
15431
+ const useExerciseLocked = () => useContext(LockContext);
15452
15432
  const PlacedInputChip = ({ widgetId, value, className = "", onRemove }) => {
15453
15433
  const { droppedSources } = useExerciseDnd();
15434
+ const locked = useExerciseLocked();
15454
15435
  const sourceOptionId = droppedSources.get(widgetId);
15455
15436
  const dragId = `${widgetId}__chip`;
15456
15437
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
15457
15438
  id: dragId,
15458
- data: { type: "placed-chip", widgetId, label: value, sourceOptionId }
15439
+ data: { type: "placed-chip", widgetId, label: value, sourceOptionId },
15440
+ disabled: locked
15459
15441
  });
15460
15442
  useDndMonitor({
15461
15443
  onDragEnd(event) {
@@ -15468,10 +15450,10 @@ const PlacedInputChip = ({ widgetId, value, className = "", onRemove }) => {
15468
15450
  ref: setNodeRef,
15469
15451
  className: `inline-input-chip ${className}${isDragging ? " dragging" : ""}`.trim(),
15470
15452
  onClick: (e) => {
15471
- if (e.detail !== 0) onRemove();
15453
+ if (!locked && e.detail !== 0) onRemove();
15472
15454
  },
15473
- title: "Drag to move, or click to remove",
15474
- style: { touchAction: "none", cursor: "grab" },
15455
+ title: locked ? void 0 : "Drag to move, or click to remove",
15456
+ style: { touchAction: "none", cursor: locked ? "default" : "grab" },
15475
15457
  ...listeners,
15476
15458
  ...attributes,
15477
15459
  children: value
@@ -15491,6 +15473,7 @@ const InlineInput = ({
15491
15473
  onFocus
15492
15474
  }) => {
15493
15475
  const playSound = useSound();
15476
+ const locked = useExerciseLocked();
15494
15477
  const handleChange = useCallback((newValue, fromDrop = false, sourceOptionId) => {
15495
15478
  onChange(widgetId, newValue, fromDrop, sourceOptionId);
15496
15479
  }, [onChange, widgetId]);
@@ -15506,6 +15489,7 @@ const InlineInput = ({
15506
15489
  id: widgetId,
15507
15490
  accepts: ["drag-option", "placed-chip"],
15508
15491
  onDrop: handleDrop,
15492
+ disabled: locked,
15509
15493
  className: "inline-widget inline-input-droppable",
15510
15494
  children: isDropped && value !== "" ? (
15511
15495
  // Show as chip only when value came from drag-drop
@@ -15528,8 +15512,11 @@ const InlineInput = ({
15528
15512
  className: `inline-input${statusClass}`,
15529
15513
  value,
15530
15514
  onChange: (e) => handleChange(e.target.value, false),
15531
- onMouseDown: () => playSound("input-select"),
15515
+ onMouseDown: () => {
15516
+ if (!locked) playSound("input-select");
15517
+ },
15532
15518
  onFocus: () => onFocus?.(widgetId),
15519
+ readOnly: locked,
15533
15520
  maxLength: expectedLength,
15534
15521
  style: { width: dynamicWidth },
15535
15522
  placeholder
@@ -15546,6 +15533,7 @@ const SingleDragOption = ({
15546
15533
  className = ""
15547
15534
  }) => {
15548
15535
  const { usedOptionIds } = useExerciseDnd();
15536
+ const locked = useExerciseLocked();
15549
15537
  const isUsed = usedOptionIds.has(optionId);
15550
15538
  return /* @__PURE__ */ jsx("div", { className: `drag-option-slot ${className}`.trim(), children: isUsed ? /* @__PURE__ */ jsx("div", { className: "drag-option-empty", "aria-hidden": "true" }) : /* @__PURE__ */ jsx(
15551
15539
  Draggable,
@@ -15553,12 +15541,16 @@ const SingleDragOption = ({
15553
15541
  id: optionId,
15554
15542
  type: "drag-option",
15555
15543
  data: { value, label: value },
15544
+ disabled: locked,
15556
15545
  className: "drag-option-wrapper",
15557
15546
  children: /* @__PURE__ */ jsx(
15558
15547
  "button",
15559
15548
  {
15560
15549
  className: "drag-option",
15561
- onClick: () => onOptionClick?.(value),
15550
+ onClick: () => {
15551
+ if (!locked) onOptionClick?.(value);
15552
+ },
15553
+ "aria-disabled": locked ? "true" : void 0,
15562
15554
  children: value
15563
15555
  }
15564
15556
  )
@@ -15672,31 +15664,34 @@ const NumericKeypad = ({
15672
15664
  onChange
15673
15665
  }) => {
15674
15666
  const playSound = useSound();
15667
+ const locked = useExerciseLocked();
15675
15668
  const handleDigit = (digit) => {
15669
+ if (locked) return;
15676
15670
  playSound("keypad-press");
15677
15671
  onChange(value + digit);
15678
15672
  };
15679
15673
  const handleBackspace = () => {
15674
+ if (locked) return;
15680
15675
  playSound("keypad-press");
15681
15676
  onChange(value.slice(0, -1));
15682
15677
  };
15683
15678
  return /* @__PURE__ */ jsxs("div", { className: `numeric-keypad ${includeOperators ? "numeric-keypad-with-operators" : ""}`, children: [
15684
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("1"), children: "1" }),
15685
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("2"), children: "2" }),
15686
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("3"), children: "3" }),
15687
- includeOperators && /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("+"), children: "+" }),
15688
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("4"), children: "4" }),
15689
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("5"), children: "5" }),
15690
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("6"), children: "6" }),
15691
- includeOperators && /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("-"), children: "-" }),
15692
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("7"), children: "7" }),
15693
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("8"), children: "8" }),
15694
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("9"), children: "9" }),
15695
- includeOperators && /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("×"), children: "×" }),
15696
- includeDecimal ? /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-decimal", onClick: () => handleDigit(","), children: "," }) : /* @__PURE__ */ jsx("div", {}),
15697
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("0"), children: "0" }),
15698
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-backspace", onClick: handleBackspace, children: "⌫" }),
15699
- includeOperators && /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("÷"), children: "÷" })
15679
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("1"), children: "1" }),
15680
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("2"), children: "2" }),
15681
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("3"), children: "3" }),
15682
+ includeOperators && /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("+"), children: "+" }),
15683
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("4"), children: "4" }),
15684
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("5"), children: "5" }),
15685
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("6"), children: "6" }),
15686
+ includeOperators && /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("-"), children: "-" }),
15687
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("7"), children: "7" }),
15688
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("8"), children: "8" }),
15689
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("9"), children: "9" }),
15690
+ includeOperators && /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("×"), children: "×" }),
15691
+ includeDecimal ? /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-decimal", onClick: () => handleDigit(","), children: "," }) : /* @__PURE__ */ jsx("div", {}),
15692
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("0"), children: "0" }),
15693
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-backspace", onClick: handleBackspace, children: "⌫" }),
15694
+ includeOperators && /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("÷"), children: "÷" })
15700
15695
  ] });
15701
15696
  };
15702
15697
  const FractionInput = ({
@@ -15713,6 +15708,7 @@ const FractionInput = ({
15713
15708
  placeholder
15714
15709
  }) => {
15715
15710
  const playSound = useSound();
15711
+ const locked = useExerciseLocked();
15716
15712
  const numKey = getFractionKey(widgetId, "num");
15717
15713
  const denKey = getFractionKey(widgetId, "den");
15718
15714
  const numValue = values[numKey] ?? "";
@@ -15733,6 +15729,7 @@ const FractionInput = ({
15733
15729
  id: subKey,
15734
15730
  accepts: ["drag-option", "placed-chip"],
15735
15731
  onDrop: handleDrop(subKey),
15732
+ disabled: locked,
15736
15733
  className: "fraction-slot-droppable",
15737
15734
  children: isDroppedHere && currentValue !== "" ? /* @__PURE__ */ jsx(
15738
15735
  PlacedInputChip,
@@ -15750,8 +15747,11 @@ const FractionInput = ({
15750
15747
  className: `inline-input fraction-input${slotClass}`,
15751
15748
  value: currentValue,
15752
15749
  onChange: (e) => onChange(subKey, e.target.value, false),
15753
- onMouseDown: () => playSound("input-select"),
15750
+ onMouseDown: () => {
15751
+ if (!locked) playSound("input-select");
15752
+ },
15754
15753
  onFocus: () => onFocus?.(subKey),
15754
+ readOnly: locked,
15755
15755
  maxLength: placeholder ? Math.max(20, placeholder.length) : 4,
15756
15756
  size: placeholder ? placeholder.length : void 0,
15757
15757
  "aria-label": label,
@@ -15982,12 +15982,13 @@ function getEqualTrianglePartPath(index, divisions) {
15982
15982
  function getGridColumnCount(divisions) {
15983
15983
  return Math.ceil(Math.sqrt(divisions));
15984
15984
  }
15985
- function getInteractiveProps(part, onPartClick) {
15985
+ function getInteractiveProps(part, onPartClick, locked = false) {
15986
15986
  if (!part.isInteractiveSlot) return {};
15987
15987
  return {
15988
15988
  onClick: () => onPartClick(part.index),
15989
15989
  role: "button",
15990
15990
  "aria-pressed": part.isSelected,
15991
+ "aria-disabled": locked ? "true" : void 0,
15991
15992
  "aria-label": getPartAriaLabel(part),
15992
15993
  tabIndex: 0,
15993
15994
  onKeyDown: (event) => {
@@ -16007,6 +16008,7 @@ const BarShape = ({
16007
16008
  orientation,
16008
16009
  onPartClick
16009
16010
  }) => {
16011
+ const locked = useExerciseLocked();
16010
16012
  const renderedParts = orientation === "vertical" ? [...parts].reverse() : parts;
16011
16013
  return /* @__PURE__ */ jsx(
16012
16014
  "div",
@@ -16018,7 +16020,7 @@ const BarShape = ({
16018
16020
  {
16019
16021
  className: part.className,
16020
16022
  style: part.style,
16021
- ...getInteractiveProps(part, onPartClick)
16023
+ ...getInteractiveProps(part, onPartClick, locked)
16022
16024
  },
16023
16025
  part.index
16024
16026
  ))
@@ -16030,87 +16032,96 @@ const GridShape = ({
16030
16032
  parts,
16031
16033
  divisions,
16032
16034
  onPartClick
16033
- }) => /* @__PURE__ */ jsx(
16034
- "div",
16035
- {
16036
- className: "visual-fraction-widget__parts visual-fraction-widget__parts--horizontal",
16037
- style: {
16038
- "--visual-fraction-divisions": divisions,
16039
- "--visual-fraction-grid-cols": getGridColumnCount(divisions),
16040
- // Safe integer division: zod schema enforces divisions = perfect square.
16041
- "--visual-fraction-grid-rows": divisions / getGridColumnCount(divisions)
16042
- },
16043
- children: parts.map((part) => /* @__PURE__ */ jsx(
16044
- "div",
16045
- {
16046
- className: part.className,
16047
- style: part.style,
16048
- ...getInteractiveProps(part, onPartClick)
16035
+ }) => {
16036
+ const locked = useExerciseLocked();
16037
+ return /* @__PURE__ */ jsx(
16038
+ "div",
16039
+ {
16040
+ className: "visual-fraction-widget__parts visual-fraction-widget__parts--horizontal",
16041
+ style: {
16042
+ "--visual-fraction-divisions": divisions,
16043
+ "--visual-fraction-grid-cols": getGridColumnCount(divisions),
16044
+ // Safe integer division: zod schema enforces divisions = perfect square.
16045
+ "--visual-fraction-grid-rows": divisions / getGridColumnCount(divisions)
16049
16046
  },
16050
- part.index
16051
- ))
16052
- }
16053
- );
16047
+ children: parts.map((part) => /* @__PURE__ */ jsx(
16048
+ "div",
16049
+ {
16050
+ className: part.className,
16051
+ style: part.style,
16052
+ ...getInteractiveProps(part, onPartClick, locked)
16053
+ },
16054
+ part.index
16055
+ ))
16056
+ }
16057
+ );
16058
+ };
16054
16059
  GridShape.displayName = "GridShape";
16055
16060
  const CircleShape = ({
16056
16061
  parts,
16057
16062
  divisions,
16058
16063
  onPartClick
16059
- }) => /* @__PURE__ */ jsx(
16060
- "svg",
16061
- {
16062
- className: "visual-fraction-widget__shape visual-fraction-widget__circle",
16063
- viewBox: "0 0 100 100",
16064
- children: parts.map((part) => {
16065
- const sectorProps = getCircleSectorProps(part.index, divisions);
16066
- const className = `${part.className} visual-fraction-widget__sector`;
16067
- const interactiveProps = getInteractiveProps(part, onPartClick);
16068
- return sectorProps.kind === "circle" ? /* @__PURE__ */ jsx(
16069
- "circle",
16070
- {
16071
- className,
16072
- style: part.style,
16073
- cx: "50",
16074
- cy: "50",
16075
- r: "48",
16076
- ...interactiveProps
16077
- },
16078
- part.index
16079
- ) : /* @__PURE__ */ jsx(
16080
- "path",
16081
- {
16082
- className,
16083
- style: part.style,
16084
- d: sectorProps.d,
16085
- ...interactiveProps
16086
- },
16087
- part.index
16088
- );
16089
- })
16090
- }
16091
- );
16064
+ }) => {
16065
+ const locked = useExerciseLocked();
16066
+ return /* @__PURE__ */ jsx(
16067
+ "svg",
16068
+ {
16069
+ className: "visual-fraction-widget__shape visual-fraction-widget__circle",
16070
+ viewBox: "0 0 100 100",
16071
+ children: parts.map((part) => {
16072
+ const sectorProps = getCircleSectorProps(part.index, divisions);
16073
+ const className = `${part.className} visual-fraction-widget__sector`;
16074
+ const interactiveProps = getInteractiveProps(part, onPartClick, locked);
16075
+ return sectorProps.kind === "circle" ? /* @__PURE__ */ jsx(
16076
+ "circle",
16077
+ {
16078
+ className,
16079
+ style: part.style,
16080
+ cx: "50",
16081
+ cy: "50",
16082
+ r: "48",
16083
+ ...interactiveProps
16084
+ },
16085
+ part.index
16086
+ ) : /* @__PURE__ */ jsx(
16087
+ "path",
16088
+ {
16089
+ className,
16090
+ style: part.style,
16091
+ d: sectorProps.d,
16092
+ ...interactiveProps
16093
+ },
16094
+ part.index
16095
+ );
16096
+ })
16097
+ }
16098
+ );
16099
+ };
16092
16100
  CircleShape.displayName = "CircleShape";
16093
16101
  const TriangleShape = ({
16094
16102
  parts,
16095
16103
  divisions,
16096
16104
  onPartClick
16097
- }) => /* @__PURE__ */ jsx(
16098
- "svg",
16099
- {
16100
- className: "visual-fraction-widget__shape visual-fraction-widget__triangle",
16101
- viewBox: "0 0 100 100",
16102
- children: parts.map((part) => /* @__PURE__ */ jsx(
16103
- "path",
16104
- {
16105
- className: `${part.className} visual-fraction-widget__sector`,
16106
- style: part.style,
16107
- d: getEqualTrianglePartPath(part.index, divisions),
16108
- ...getInteractiveProps(part, onPartClick)
16109
- },
16110
- part.index
16111
- ))
16112
- }
16113
- );
16105
+ }) => {
16106
+ const locked = useExerciseLocked();
16107
+ return /* @__PURE__ */ jsx(
16108
+ "svg",
16109
+ {
16110
+ className: "visual-fraction-widget__shape visual-fraction-widget__triangle",
16111
+ viewBox: "0 0 100 100",
16112
+ children: parts.map((part) => /* @__PURE__ */ jsx(
16113
+ "path",
16114
+ {
16115
+ className: `${part.className} visual-fraction-widget__sector`,
16116
+ style: part.style,
16117
+ d: getEqualTrianglePartPath(part.index, divisions),
16118
+ ...getInteractiveProps(part, onPartClick, locked)
16119
+ },
16120
+ part.index
16121
+ ))
16122
+ }
16123
+ );
16124
+ };
16114
16125
  TriangleShape.displayName = "TriangleShape";
16115
16126
  const ShapeBar = ({ parts, divisions, orientation, onPartClick }) => /* @__PURE__ */ jsx(BarShape, { parts, divisions, orientation, onPartClick });
16116
16127
  const ShapeCircle = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsx(CircleShape, { parts, divisions, onPartClick });
@@ -16154,12 +16165,14 @@ const VisualFraction = ({
16154
16165
  onChange
16155
16166
  });
16156
16167
  const playSound = useSound();
16168
+ const locked = useExerciseLocked();
16157
16169
  const handlePartClickWithSound = useCallback(
16158
16170
  (index) => {
16171
+ if (locked) return;
16159
16172
  if (answer.length > 0 && index >= filledCount) playSound("fraction-shade");
16160
16173
  handlePartClick(index);
16161
16174
  },
16162
- [answer.length, filledCount, playSound, handlePartClick]
16175
+ [locked, answer.length, filledCount, playSound, handlePartClick]
16163
16176
  );
16164
16177
  const parts = useMemo(
16165
16178
  () => Array.from({ length: divisions }, (_, i) => {
@@ -16234,6 +16247,7 @@ const GridSymmetry = ({
16234
16247
  validationStatus = null,
16235
16248
  onChange
16236
16249
  }) => {
16250
+ const exerciseLocked = useExerciseLocked();
16237
16251
  const initialKeys = useMemo(() => new Set(widget.cells.map(gridCellKey)), [widget.cells]);
16238
16252
  const initialInteractiveCells = useMemo(
16239
16253
  () => widget.cells.filter(
@@ -16244,6 +16258,7 @@ const GridSymmetry = ({
16244
16258
  const selectedCells = value ?? initialInteractiveCells;
16245
16259
  const selectedKeys = useMemo(() => new Set(selectedCells.map(gridCellKey)), [selectedCells]);
16246
16260
  const toggleCell = (cell) => {
16261
+ if (exerciseLocked) return;
16247
16262
  const key2 = gridCellKey(cell);
16248
16263
  if (initialKeys.has(key2)) return;
16249
16264
  const next = selectedKeys.has(key2) ? selectedCells.filter((candidate) => gridCellKey(candidate) !== key2) : [...selectedCells, cell];
@@ -16284,6 +16299,7 @@ const GridSymmetry = ({
16284
16299
  className,
16285
16300
  "aria-label": label,
16286
16301
  "aria-pressed": selected,
16302
+ "aria-disabled": exerciseLocked ? "true" : void 0,
16287
16303
  onClick: () => toggleCell(cell)
16288
16304
  },
16289
16305
  key2
@@ -16323,6 +16339,7 @@ const RadioOptions = ({
16323
16339
  onChange
16324
16340
  }) => {
16325
16341
  const playSound = useSound();
16342
+ const locked = useExerciseLocked();
16326
16343
  return /* @__PURE__ */ jsx("div", { className: "radio-options", children: options.map((option, idx) => {
16327
16344
  const isSelected = value === option;
16328
16345
  const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
@@ -16331,9 +16348,11 @@ const RadioOptions = ({
16331
16348
  {
16332
16349
  className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
16333
16350
  onClick: () => {
16351
+ if (locked) return;
16334
16352
  playSound("radio-select");
16335
16353
  onChange(name, option);
16336
16354
  },
16355
+ "aria-disabled": locked ? "true" : void 0,
16337
16356
  type: "button",
16338
16357
  children: option
16339
16358
  },
@@ -16345,7 +16364,10 @@ const Btn = ({
16345
16364
  label,
16346
16365
  extraClass = "keypad-btn-digit",
16347
16366
  onClick
16348
- }) => /* @__PURE__ */ jsx("button", { className: `keypad-btn ${extraClass}`, onClick, children: /* @__PURE__ */ jsx("span", { children: label }) });
16367
+ }) => {
16368
+ const locked = useExerciseLocked();
16369
+ return /* @__PURE__ */ jsx("button", { className: `keypad-btn ${extraClass}`, disabled: locked, onClick, children: /* @__PURE__ */ jsx("span", { children: label }) });
16370
+ };
16349
16371
  const DiamondKeypad = ({ value, onChange }) => {
16350
16372
  const playSound = useSound();
16351
16373
  const append = (ch) => {
@@ -16432,8 +16454,10 @@ const MatcherWidget = ({
16432
16454
  () => defaultValue2 && defaultValue2.length > 0 ? assignmentsFromValue(defaultValue2, itemIds, groupCount) : initialAssignments(itemIds)
16433
16455
  );
16434
16456
  const playSound = useSound();
16457
+ const locked = useExerciseLocked();
16435
16458
  const handleCardClick = useCallback(
16436
16459
  (itemId) => {
16460
+ if (locked) return;
16437
16461
  playSound("matcher-select");
16438
16462
  setAssignments((prev) => {
16439
16463
  const current = prev[itemId] ?? null;
@@ -16441,7 +16465,7 @@ const MatcherWidget = ({
16441
16465
  return { ...prev, [itemId]: next };
16442
16466
  });
16443
16467
  },
16444
- [groupCount, playSound]
16468
+ [groupCount, playSound, locked]
16445
16469
  );
16446
16470
  const onChangeRef = useRef(onChange);
16447
16471
  onChangeRef.current = onChange;
@@ -16478,6 +16502,7 @@ const MatcherWidget = ({
16478
16502
  "--matcher-card-text-color": `var(--matcher-group-${colorIdx + 1}-text-color, ${textColors[colorIdx]})`
16479
16503
  } : void 0,
16480
16504
  onClick: () => handleCardClick(item.itemId),
16505
+ "aria-disabled": locked ? "true" : void 0,
16481
16506
  "aria-pressed": isSelected,
16482
16507
  "aria-label": isSelected ? `Assigned to group ${colorIdx + 1}` : "Not assigned",
16483
16508
  children: item.content.map((layoutItem) => /* @__PURE__ */ jsx(
@@ -16690,7 +16715,10 @@ const PairMatcherWidget = ({
16690
16715
  widgets,
16691
16716
  shuffleItems = true
16692
16717
  }) => {
16693
- const { topItems, bottomItems } = widget;
16718
+ const { groups, layout } = widget;
16719
+ const [topItems, bottomItems] = groups;
16720
+ const isColumns = layout === "columns";
16721
+ const exerciseLocked = useExerciseLocked();
16694
16722
  const [shuffledTop] = useState(
16695
16723
  () => shuffleItems ? lodashExports.shuffle([...topItems]) : [...topItems]
16696
16724
  );
@@ -16734,8 +16762,9 @@ const PairMatcherWidget = ({
16734
16762
  }
16735
16763
  soundPrevRef.current = { count, pendingId, flashId };
16736
16764
  }, [state.pairs, state.pending, state.flashTopId, playSound]);
16765
+ const interactionLocked = validationStatus !== null || exerciseLocked;
16737
16766
  const handleTopClick = useCallback((topId) => {
16738
- if (validationStatus) return;
16767
+ if (interactionLocked) return;
16739
16768
  if (flashTimerRef.current) {
16740
16769
  clearTimeout(flashTimerRef.current);
16741
16770
  flashTimerRef.current = null;
@@ -16777,9 +16806,9 @@ const PairMatcherWidget = ({
16777
16806
  flashBottomId: null
16778
16807
  };
16779
16808
  });
16780
- }, [validationStatus]);
16809
+ }, [interactionLocked]);
16781
16810
  const handleBottomClick = useCallback((bottomId) => {
16782
- if (validationStatus) return;
16811
+ if (interactionLocked) return;
16783
16812
  if (flashTimerRef.current) {
16784
16813
  clearTimeout(flashTimerRef.current);
16785
16814
  flashTimerRef.current = null;
@@ -16822,9 +16851,13 @@ const PairMatcherWidget = ({
16822
16851
  flashBottomId: null
16823
16852
  };
16824
16853
  });
16825
- }, [validationStatus]);
16854
+ }, [interactionLocked]);
16826
16855
  useEffect(() => {
16827
- if (validationStatus || state.flashTopId === null || state.flashBottomId === null) return;
16856
+ if (state.flashTopId === null || state.flashBottomId === null) return;
16857
+ if (interactionLocked) {
16858
+ setState((current) => ({ ...current, flashTopId: null, flashBottomId: null }));
16859
+ return;
16860
+ }
16828
16861
  flashTimerRef.current = setTimeout(() => {
16829
16862
  flashTimerRef.current = null;
16830
16863
  setState((current) => {
@@ -16838,7 +16871,7 @@ const PairMatcherWidget = ({
16838
16871
  flashTimerRef.current = null;
16839
16872
  }
16840
16873
  };
16841
- }, [state.flashTopId, state.flashBottomId, validationStatus]);
16874
+ }, [state.flashTopId, state.flashBottomId, interactionLocked]);
16842
16875
  const onChangeRef = useRef(onChange);
16843
16876
  onChangeRef.current = onChange;
16844
16877
  const isMounted = useRef(false);
@@ -16861,8 +16894,8 @@ const PairMatcherWidget = ({
16861
16894
  pairStatusByTopId
16862
16895
  });
16863
16896
  const cols = useMemo(
16864
- () => Math.max(topItems.length, bottomItems.length, 1),
16865
- [topItems.length, bottomItems.length]
16897
+ () => isColumns ? null : Math.max(topItems.length, bottomItems.length, 1),
16898
+ [isColumns, topItems.length, bottomItems.length]
16866
16899
  );
16867
16900
  const renderCard = (item, isTop) => {
16868
16901
  const isRtl = shouldItemsUseRTL(item.content);
@@ -16898,7 +16931,7 @@ const PairMatcherWidget = ({
16898
16931
  dir: isRtl ? "rtl" : void 0,
16899
16932
  onClick: () => isTop ? handleTopClick(item.itemId) : handleBottomClick(item.itemId),
16900
16933
  "aria-pressed": status !== "idle",
16901
- "aria-disabled": validationStatus ? "true" : void 0,
16934
+ "aria-disabled": interactionLocked ? "true" : void 0,
16902
16935
  children: /* @__PURE__ */ jsx("span", { className: "pair-matcher-card-content", children: item.content.map((li) => renderPairMatcherItemContent(li, widgetsMap)) })
16903
16936
  }
16904
16937
  ),
@@ -16908,8 +16941,8 @@ const PairMatcherWidget = ({
16908
16941
  return /* @__PURE__ */ jsxs(
16909
16942
  "div",
16910
16943
  {
16911
- className: `pair-matcher-widget${validationStatus ? ` pair-matcher-widget--${validationStatus}` : ""}`,
16912
- style: { "--pm-cols": cols },
16944
+ className: `pair-matcher-widget${isColumns ? " pair-matcher-widget--columns" : ""}${validationStatus ? ` pair-matcher-widget--${validationStatus}` : ""}`,
16945
+ style: cols === null ? {} : { "--pm-cols": cols },
16913
16946
  ref: containerRef,
16914
16947
  role: "group",
16915
16948
  "aria-label": "Pair matcher exercise",
@@ -17437,6 +17470,7 @@ const DraggableCursor = ({
17437
17470
  currentValue,
17438
17471
  validationStatus
17439
17472
  }) => {
17473
+ const locked = useExerciseLocked();
17440
17474
  const [isDragging, setIsDragging] = useState(false);
17441
17475
  const isDraggingRef = useRef(false);
17442
17476
  const currentValueRef = useRef(currentValue);
@@ -17458,13 +17492,14 @@ const DraggableCursor = ({
17458
17492
  return snapTicks.length > 0 ? snapToNearest(raw, snapTicks) : cleanFloat(raw);
17459
17493
  }, [axisRowRef, min, max, snapTicks]);
17460
17494
  const handlePointerDown = useCallback((e) => {
17495
+ if (locked) return;
17461
17496
  e.currentTarget.setPointerCapture(e.pointerId);
17462
17497
  e.stopPropagation();
17463
17498
  isDraggingRef.current = true;
17464
17499
  dragStartValueRef.current = currentValue;
17465
17500
  lastSoundValueRef.current = currentValue;
17466
17501
  setIsDragging(true);
17467
- }, [currentValue]);
17502
+ }, [currentValue, locked]);
17468
17503
  const updateCursorValue = useCallback((clientX) => {
17469
17504
  const next = snap(clientX);
17470
17505
  if (!almostEqual(next, lastSoundValueRef.current)) {
@@ -17492,7 +17527,7 @@ const DraggableCursor = ({
17492
17527
  }
17493
17528
  }, [updateCursorValue, playSound]);
17494
17529
  const handleKeyDown = useCallback((e) => {
17495
- if (!snapTicks.length) return;
17530
+ if (locked || !snapTicks.length) return;
17496
17531
  const sorted = [...snapTicks].sort((a, b) => a - b);
17497
17532
  const idx = sorted.findIndex((t) => t >= currentValue - 1e-9);
17498
17533
  const first = sorted[0] ?? currentValue;
@@ -17514,7 +17549,7 @@ const DraggableCursor = ({
17514
17549
  }
17515
17550
  onChange(cursor.id, String(next));
17516
17551
  }
17517
- }, [snapTicks, audibleTicks, currentValue, playSound, onChange, cursor.id]);
17552
+ }, [locked, snapTicks, audibleTicks, currentValue, playSound, onChange, cursor.id]);
17518
17553
  const ballClass = [
17519
17554
  "number-line-cursor-ball",
17520
17555
  isDragging ? "number-line-cursor-ball--dragging" : "",
@@ -17563,6 +17598,7 @@ const DraggableCursor = ({
17563
17598
  "aria-valuemax": max,
17564
17599
  "aria-valuenow": currentValue,
17565
17600
  "aria-label": sliderAriaLabel,
17601
+ "aria-disabled": locked ? "true" : void 0,
17566
17602
  tabIndex: 0
17567
17603
  }
17568
17604
  )
@@ -17603,9 +17639,11 @@ const CursorBalls = ({
17603
17639
  }) });
17604
17640
  };
17605
17641
  const PlacedChip = ({ widgetId, label, leftPercent, isLanding, onRemove }) => {
17642
+ const locked = useExerciseLocked();
17606
17643
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
17607
17644
  id: widgetId,
17608
- data: { type: "placed-chip", widgetId, label }
17645
+ data: { type: "placed-chip", widgetId, label },
17646
+ disabled: locked
17609
17647
  });
17610
17648
  return /* @__PURE__ */ jsx(
17611
17649
  "div",
@@ -17630,6 +17668,7 @@ const PlacedChip = ({ widgetId, label, leftPercent, isLanding, onRemove }) => {
17630
17668
  type: "button",
17631
17669
  className: "number-line-placed-chip-remove",
17632
17670
  "aria-label": "Remove",
17671
+ disabled: locked,
17633
17672
  onClick: onRemove,
17634
17673
  children: "✕"
17635
17674
  }
@@ -17669,6 +17708,7 @@ const FreeDragAxis = ({
17669
17708
  cursorValidationStatuses,
17670
17709
  snapKey
17671
17710
  }) => {
17711
+ const axisLocked = useExerciseLocked();
17672
17712
  const axisRef = useRef(null);
17673
17713
  const snapTicks = useMemo(
17674
17714
  () => getSnapTicks(majorTicks, minorTicks),
@@ -17724,7 +17764,7 @@ const FreeDragAxis = ({
17724
17764
  return changed ? next : prev;
17725
17765
  });
17726
17766
  }, [values]);
17727
- const { setNodeRef } = useDroppable({ id: axisDropId });
17767
+ const { setNodeRef } = useDroppable({ id: axisDropId, disabled: axisLocked });
17728
17768
  const mergedRef = useCallback((node) => {
17729
17769
  axisRef.current = node;
17730
17770
  setNodeRef(node);
@@ -18038,6 +18078,7 @@ const CounterControls = ({
18038
18078
  onIncrement
18039
18079
  }) => {
18040
18080
  const playSound = useSound();
18081
+ const locked = useExerciseLocked();
18041
18082
  return /* @__PURE__ */ jsxs("div", { className: "number-line-counter", "aria-label": counter.name ?? "Counter", children: [
18042
18083
  /* @__PURE__ */ jsxs("div", { className: "number-line-counter-controls", children: [
18043
18084
  /* @__PURE__ */ jsx(
@@ -18046,7 +18087,7 @@ const CounterControls = ({
18046
18087
  type: "button",
18047
18088
  className: "number-line-counter-btn",
18048
18089
  "aria-label": "Decrease",
18049
- disabled: count <= counter.min,
18090
+ disabled: locked || count <= counter.min,
18050
18091
  onClick: () => {
18051
18092
  playSound("counter-step");
18052
18093
  onDecrement();
@@ -18061,7 +18102,7 @@ const CounterControls = ({
18061
18102
  type: "button",
18062
18103
  className: "number-line-counter-btn",
18063
18104
  "aria-label": "Increase",
18064
- disabled: count >= counter.max,
18105
+ disabled: locked || count >= counter.max,
18065
18106
  onClick: () => {
18066
18107
  playSound("counter-step");
18067
18108
  onIncrement();
@@ -18645,6 +18686,7 @@ const ClockRenderer = ({
18645
18686
  validationStatus,
18646
18687
  onChange
18647
18688
  }) => {
18689
+ const locked = useExerciseLocked();
18648
18690
  const format = item.format ?? "24h";
18649
18691
  const interactive = item.mode === "interactive";
18650
18692
  const snap = interactive && "snap" in item ? item.snap ?? 1 : 1;
@@ -18690,18 +18732,18 @@ const ClockRenderer = ({
18690
18732
  }
18691
18733
  }, [snap, hours, minutes, item.mode, item.name, onChange, playSound]);
18692
18734
  const onPointerDown = (hand) => (e) => {
18693
- if (!interactive) return;
18735
+ if (!interactive || locked) return;
18694
18736
  e.preventDefault();
18695
18737
  e.target.setPointerCapture?.(e.pointerId);
18696
18738
  draggingRef.current = hand;
18697
18739
  updateFromPointer(e.clientX, e.clientY);
18698
18740
  };
18699
18741
  const onPointerMove = (e) => {
18700
- if (!interactive || !draggingRef.current) return;
18742
+ if (!interactive || locked || !draggingRef.current) return;
18701
18743
  updateFromPointer(e.clientX, e.clientY);
18702
18744
  };
18703
18745
  const onPointerUp = (e) => {
18704
- if (!interactive) return;
18746
+ if (!interactive || locked) return;
18705
18747
  e.target.releasePointerCapture?.(e.pointerId);
18706
18748
  draggingRef.current = null;
18707
18749
  };
@@ -19253,38 +19295,45 @@ const ExerciseRenderer = forwardRef(({
19253
19295
  const [droppedSources, setDroppedSources] = useState(/* @__PURE__ */ new Map());
19254
19296
  const [focusedInputId, setFocusedInputId] = useState(null);
19255
19297
  const [validationDetails, setValidationDetails] = useState(null);
19298
+ const rootRef = useRef(null);
19256
19299
  const [resetKey, setResetKey] = useState(0);
19257
19300
  const [revealed, setRevealed] = useState(false);
19258
19301
  const playSound = useSoundPlayer(soundEnabled, sounds);
19259
- useImperativeHandle(ref, () => ({
19260
- submit: () => {
19261
- const result = validate(json, values);
19262
- playSound(result.isCorrect ? "answer-correct" : "answer-wrong");
19263
- const details = { ...result.details };
19264
- const slotValidation = (id, raw, expected) => ({
19265
- widgetId: id,
19266
- userValue: typeof raw === "string" ? raw : void 0,
19267
- isCorrect: typeof raw === "string" && raw !== "" && normalize(raw) === normalize(expected)
19268
- });
19269
- for (const [widgetId, widget] of Object.entries(json.widgets)) {
19270
- if (widget.type !== JSON_TYPES.FRACTION) continue;
19271
- if (!(widgetId in details)) continue;
19272
- const answer = json.validation?.answers?.[widgetId];
19273
- if (!answer) continue;
19274
- const [expectedNum, expectedDen] = answer.split("/", 2);
19275
- if (!expectedNum || !expectedDen) continue;
19276
- if (details[widgetId]?.isCorrect) {
19277
- if (widget.numerator === null) details[`${widgetId}__num`] = { widgetId: `${widgetId}__num`, userValue: void 0, isCorrect: true };
19278
- if (widget.denominator === null) details[`${widgetId}__den`] = { widgetId: `${widgetId}__den`, userValue: void 0, isCorrect: true };
19279
- } else {
19280
- if (widget.numerator === null) details[`${widgetId}__num`] = slotValidation(`${widgetId}__num`, values[`${widgetId}__num`], expectedNum);
19281
- if (widget.denominator === null) details[`${widgetId}__den`] = slotValidation(`${widgetId}__den`, values[`${widgetId}__den`], expectedDen);
19282
- }
19302
+ const externallyLocked = useExerciseLocked();
19303
+ const locked = validationDetails !== null || externallyLocked;
19304
+ const lastResultRef = useRef(null);
19305
+ const runSubmit = () => {
19306
+ if (locked) return lastResultRef.current ?? validate(json, values);
19307
+ const result = validate(json, values);
19308
+ playSound(result.isCorrect ? "answer-correct" : "answer-wrong");
19309
+ const details = { ...result.details };
19310
+ const slotValidation = (id, raw, expected) => ({
19311
+ widgetId: id,
19312
+ userValue: typeof raw === "string" ? raw : void 0,
19313
+ isCorrect: typeof raw === "string" && raw !== "" && normalize(raw) === normalize(expected)
19314
+ });
19315
+ for (const [widgetId, widget] of Object.entries(json.widgets)) {
19316
+ if (widget.type !== JSON_TYPES.FRACTION) continue;
19317
+ if (!(widgetId in details)) continue;
19318
+ const answer = json.validation?.answers?.[widgetId];
19319
+ if (!answer) continue;
19320
+ const [expectedNum, expectedDen] = answer.split("/", 2);
19321
+ if (!expectedNum || !expectedDen) continue;
19322
+ if (details[widgetId]?.isCorrect) {
19323
+ if (widget.numerator === null) details[`${widgetId}__num`] = { widgetId: `${widgetId}__num`, userValue: void 0, isCorrect: true };
19324
+ if (widget.denominator === null) details[`${widgetId}__den`] = { widgetId: `${widgetId}__den`, userValue: void 0, isCorrect: true };
19325
+ } else {
19326
+ if (widget.numerator === null) details[`${widgetId}__num`] = slotValidation(`${widgetId}__num`, values[`${widgetId}__num`], expectedNum);
19327
+ if (widget.denominator === null) details[`${widgetId}__den`] = slotValidation(`${widgetId}__den`, values[`${widgetId}__den`], expectedDen);
19283
19328
  }
19284
- setValidationDetails(details);
19285
- onSubmit?.(values);
19286
- return result;
19287
- },
19329
+ }
19330
+ setValidationDetails(details);
19331
+ onSubmit?.(values);
19332
+ lastResultRef.current = result;
19333
+ return result;
19334
+ };
19335
+ useImperativeHandle(ref, () => ({
19336
+ submit: runSubmit,
19288
19337
  getValues: () => values,
19289
19338
  getCompletion: () => {
19290
19339
  const answerable = getAnswerableInputs(json);
@@ -19327,14 +19376,20 @@ const ExerciseRenderer = forwardRef(({
19327
19376
  setResetKey((k) => k + 1);
19328
19377
  },
19329
19378
  focusFirstInput: () => {
19330
- const container = document.querySelector(".math-exercise-renderer");
19331
- const firstInput = container?.querySelector('input[type="text"], input[type="number"], textarea, select');
19332
- if (firstInput instanceof HTMLElement) {
19333
- firstInput.focus();
19379
+ const container = rootRef.current;
19380
+ if (!container) return;
19381
+ const candidates = container.querySelectorAll(
19382
+ 'input[type="text"], input[type="number"], textarea, select'
19383
+ );
19384
+ for (const el of Array.from(candidates)) {
19385
+ if (el.readOnly || el.disabled) continue;
19386
+ el.focus();
19387
+ return;
19334
19388
  }
19335
19389
  }
19336
- }), [json, values, onSubmit, playSound]);
19390
+ }), [json, values, onSubmit, playSound, locked]);
19337
19391
  const handleChange = (widgetId, value, fromDrop = false, sourceOptionId) => {
19392
+ if (locked) return;
19338
19393
  setRevealed(false);
19339
19394
  const next = { ...valuesRef.current, [widgetId]: value };
19340
19395
  valuesRef.current = next;
@@ -19351,7 +19406,7 @@ const ExerciseRenderer = forwardRef(({
19351
19406
  onChange?.(next);
19352
19407
  };
19353
19408
  const handleSubmit = () => {
19354
- onSubmit?.(values);
19409
+ runSubmit();
19355
19410
  };
19356
19411
  const lineGroups = useMemo(() => {
19357
19412
  const isRTLInlineWidget = (item) => {
@@ -19466,7 +19521,7 @@ const ExerciseRenderer = forwardRef(({
19466
19521
  if (setSquareWidget) add2("set-square-1", "set-square", Math.round(getSize(setSquareWidget) * 160));
19467
19522
  return { tools: list, toolsMinHeight: maxH };
19468
19523
  }, [rulerWidget, protractorWidget, setSquareWidget]);
19469
- return /* @__PURE__ */ jsx(SoundPlayerProvider, { play: playSound, children: /* @__PURE__ */ jsx(ExerciseDndProvider, { droppedSources, children: /* @__PURE__ */ jsxs("div", { className: `math-exercise-renderer ${revealed ? "revealed" : ""} ${className}`, children: [
19524
+ return /* @__PURE__ */ jsx(SoundPlayerProvider, { play: playSound, children: /* @__PURE__ */ jsx(ExerciseLockProvider, { locked, children: /* @__PURE__ */ jsx(ExerciseDndProvider, { droppedSources, children: /* @__PURE__ */ jsxs("div", { ref: rootRef, className: `math-exercise-renderer ${revealed ? "revealed" : ""} ${locked ? "locked" : ""} ${className}`, children: [
19470
19525
  /* @__PURE__ */ jsxs(
19471
19526
  "div",
19472
19527
  {
@@ -19562,8 +19617,8 @@ const ExerciseRenderer = forwardRef(({
19562
19617
  }
19563
19618
  }
19564
19619
  ) }),
19565
- showSubmitButton && /* @__PURE__ */ jsx("button", { className: "exercise-submit-btn", onClick: handleSubmit, children: "Check Answer" })
19566
- ] }) }) });
19620
+ showSubmitButton && /* @__PURE__ */ jsx("button", { className: "exercise-submit-btn", disabled: locked, onClick: handleSubmit, children: "Check Answer" })
19621
+ ] }) }) }) });
19567
19622
  });
19568
19623
  ExerciseRenderer.displayName = "ExerciseRenderer";
19569
19624
  function renderExercise(options) {
@@ -19620,6 +19675,7 @@ export {
19620
19675
  EngineError,
19621
19676
  ExerciseDocumentSchema,
19622
19677
  ExerciseJSONSchema,
19678
+ ExerciseLockProvider,
19623
19679
  ExerciseMetaSchema,
19624
19680
  ExerciseRenderer,
19625
19681
  ExpressionValidationSchema,
@@ -19842,6 +19898,7 @@ export {
19842
19898
  toJSON,
19843
19899
  tokenize,
19844
19900
  tokenizeSafe,
19901
+ useExerciseLocked,
19845
19902
  useSound,
19846
19903
  validate,
19847
19904
  validateBooleanExpression,