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