@mathscan/math-exercise-engine 1.0.11 → 1.0.13

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
@@ -23,6 +23,8 @@ const AST_TYPES = {
23
23
  BOLD_TEXT: "BoldText",
24
24
  /** Inline input widget (string or numeric) */
25
25
  INLINE_INPUT: "InlineInput",
26
+ /** Inline fraction widget (numerator/denominator with optional inputs) */
27
+ FRACTION: "Fraction",
26
28
  // ═══════════════════════════════════════════════════════════════════════════
27
29
  // Blocks
28
30
  // ═══════════════════════════════════════════════════════════════════════════
@@ -40,6 +42,10 @@ const AST_TYPES = {
40
42
  NUMBER_LINE_BLOCK: "NumberLineBlock",
41
43
  /** A position marker on the number line */
42
44
  NUMBER_LINE_POSITION: "NumberLinePosition",
45
+ /** A dynamic subdivision counter embedded in a number line */
46
+ NUMBER_LINE_COUNTER: "NumberLineCounter",
47
+ /** A highlighted range on the number line axis */
48
+ NUMBER_LINE_HIGHLIGHT: "NumberLineHighlight",
43
49
  // ═══════════════════════════════════════════════════════════════════════════
44
50
  // Tools
45
51
  // ═══════════════════════════════════════════════════════════════════════════
@@ -96,6 +102,10 @@ const JSON_TYPES = {
96
102
  NUMBER_LINE: "number-line",
97
103
  /** Position marker within a number line */
98
104
  NUMBER_LINE_POSITION: "number-line-position",
105
+ /** Dynamic subdivision counter embedded in a number line */
106
+ NUMBER_LINE_COUNTER: "number-line-counter",
107
+ /** Highlighted range within a number line */
108
+ NUMBER_LINE_HIGHLIGHT: "number-line-highlight",
99
109
  // ═══════════════════════════════════════════════════════════════════════════
100
110
  // Widget Types
101
111
  // ═══════════════════════════════════════════════════════════════════════════
@@ -106,7 +116,9 @@ const JSON_TYPES = {
106
116
  /** Radio button options widget */
107
117
  RADIO_OPTIONS: "radio-options",
108
118
  /** Numeric keypad widget */
109
- NUMERIC_KEYPAD: "numeric-keypad"
119
+ NUMERIC_KEYPAD: "numeric-keypad",
120
+ /** Inline fraction widget */
121
+ FRACTION: "fraction"
110
122
  };
111
123
  const DRAG_OPTIONS_WIDGET_ID = "_drag_options";
112
124
  const VALIDATION_MODES = {
@@ -122,8 +134,23 @@ const INPUT_TYPES = {
122
134
  NUMERIC: "numeric"
123
135
  };
124
136
  const NUMBER_LINE_STYLES = ["arrow-right", "arrow-left", "arrow-both", "line"];
137
+ const INTERNAL_PRECISION = 9;
138
+ const DISPLAY_PRECISION = 2;
125
139
  const SIDE_PAD = 7;
126
140
  const CSS_VAR_SIDE_PAD = "--nl-side-pad";
141
+ const NUMBER_LINE_LABELS = ["all", "extremes", "min-only", "max-only", "none"];
142
+ const NUMBER_LINE_HIGHLIGHT_COLORS = ["blue", "red", "green", "orange", "purple", "yellow"];
143
+ function getFractionKey(widgetId, part) {
144
+ return `${widgetId}__${part}`;
145
+ }
146
+ function isFractionKey(key2) {
147
+ return key2.endsWith("__num") || key2.endsWith("__den");
148
+ }
149
+ function parseFractionKey(key2) {
150
+ const match = key2.match(/^(.+)__(num|den)$/);
151
+ if (!match) return null;
152
+ return { widgetId: match[1], part: match[2] };
153
+ }
127
154
  const Newline = chevrotain.createToken({
128
155
  name: "Newline",
129
156
  pattern: /\r?\n/,
@@ -217,6 +244,18 @@ const CELL = chevrotain.createToken({
217
244
  name: "CELL",
218
245
  pattern: /CELL/
219
246
  });
247
+ const COUNTER = chevrotain.createToken({
248
+ name: "COUNTER",
249
+ pattern: /COUNTER/
250
+ });
251
+ const HIGHLIGHT = chevrotain.createToken({
252
+ name: "HIGHLIGHT",
253
+ pattern: /HIGHLIGHT(?!_TEXT)/
254
+ });
255
+ const FRACTION = chevrotain.createToken({
256
+ name: "FRACTION",
257
+ pattern: /FRACTION/
258
+ });
220
259
  const NumberLiteral = chevrotain.createToken({
221
260
  name: "NumberLiteral",
222
261
  pattern: /-?\d+(\.\d+)?/
@@ -324,6 +363,9 @@ const allTokens = [
324
363
  TABLE,
325
364
  ROW,
326
365
  CELL,
366
+ COUNTER,
367
+ HIGHLIGHT,
368
+ FRACTION,
327
369
  // Literals
328
370
  NumberLiteral,
329
371
  StringLiteral,
@@ -431,10 +473,7 @@ class MathExerciseParser extends chevrotain.CstParser {
431
473
  });
432
474
  this.exerciseDocument = this.RULE("exerciseDocument", () => {
433
475
  this.MANY(() => {
434
- this.OR([
435
- { ALT: () => this.CONSUME(Newline) },
436
- { ALT: () => this.CONSUME(Space) }
437
- ]);
476
+ this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
438
477
  });
439
478
  this.OPTION(() => {
440
479
  this.SUBRULE(this.question);
@@ -450,10 +489,7 @@ class MathExerciseParser extends chevrotain.CstParser {
450
489
  this.SUBRULE(this.validation);
451
490
  });
452
491
  this.MANY4(() => {
453
- this.OR2([
454
- { ALT: () => this.CONSUME2(Newline) },
455
- { ALT: () => this.CONSUME2(Space) }
456
- ]);
492
+ this.OR2([{ ALT: () => this.CONSUME2(Newline) }, { ALT: () => this.CONSUME2(Space) }]);
457
493
  });
458
494
  });
459
495
  this.question = this.RULE("question", () => {
@@ -462,10 +498,7 @@ class MathExerciseParser extends chevrotain.CstParser {
462
498
  this.CONSUME(StringLiteral, { LABEL: "value" });
463
499
  this.CONSUME(RBracket);
464
500
  this.MANY(() => {
465
- this.OR([
466
- { ALT: () => this.CONSUME(Newline) },
467
- { ALT: () => this.CONSUME(Space) }
468
- ]);
501
+ this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
469
502
  });
470
503
  });
471
504
  this.contentBlock = this.RULE("contentBlock", () => {
@@ -487,77 +520,50 @@ class MathExerciseParser extends chevrotain.CstParser {
487
520
  });
488
521
  this.table = this.RULE("table", () => {
489
522
  this.MANY(() => {
490
- this.OR2([
491
- { ALT: () => this.CONSUME(Space) },
492
- { ALT: () => this.CONSUME(Newline) }
493
- ]);
523
+ this.OR2([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
494
524
  });
495
525
  this.CONSUME(TABLE);
496
526
  this.CONSUME(LBracket);
497
527
  this.SUBRULE(this.parameterList);
498
528
  this.CONSUME(RBracket);
499
529
  this.MANY2(() => {
500
- this.OR3([
501
- { ALT: () => this.CONSUME2(Space) },
502
- { ALT: () => this.CONSUME2(Newline) }
503
- ]);
530
+ this.OR3([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
504
531
  });
505
532
  this.CONSUME(LBrace);
506
533
  this.MANY3(() => {
507
- this.OR4([
508
- { ALT: () => this.CONSUME3(Space) },
509
- { ALT: () => this.CONSUME3(Newline) }
510
- ]);
534
+ this.OR4([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
511
535
  });
512
536
  this.AT_LEAST_ONE(() => {
513
537
  this.SUBRULE(this.row);
514
538
  });
515
539
  this.CONSUME(RBrace);
516
540
  this.MANY4(() => {
517
- this.OR5([
518
- { ALT: () => this.CONSUME4(Newline) },
519
- { ALT: () => this.CONSUME4(Space) }
520
- ]);
541
+ this.OR5([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
521
542
  });
522
543
  });
523
544
  this.row = this.RULE("row", () => {
524
545
  this.MANY(() => {
525
- this.OR([
526
- { ALT: () => this.CONSUME(Space) },
527
- { ALT: () => this.CONSUME(Newline) }
528
- ]);
546
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
529
547
  });
530
548
  this.CONSUME(ROW);
531
549
  this.MANY2(() => {
532
- this.OR2([
533
- { ALT: () => this.CONSUME2(Space) },
534
- { ALT: () => this.CONSUME2(Newline) }
535
- ]);
550
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
536
551
  });
537
552
  this.CONSUME(LBrace);
538
553
  this.MANY3(() => {
539
- this.OR3([
540
- { ALT: () => this.CONSUME3(Space) },
541
- { ALT: () => this.CONSUME3(Newline) }
542
- ]);
554
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
543
555
  });
544
556
  this.AT_LEAST_ONE(() => {
545
557
  this.SUBRULE(this.cell);
546
558
  });
547
559
  this.CONSUME(RBrace);
548
560
  this.MANY4(() => {
549
- this.OR4([
550
- { ALT: () => this.CONSUME4(Space) },
551
- { ALT: () => this.CONSUME4(Newline) }
552
- ]);
561
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
553
562
  });
554
563
  });
555
564
  this.cell = this.RULE("cell", () => {
556
565
  this.MANY(() => {
557
- this.OR([
558
- { ALT: () => this.CONSUME(Space) },
559
- { ALT: () => this.CONSUME(Newline) }
560
- ]);
566
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
561
567
  });
562
568
  this.CONSUME(CELL);
563
569
  this.OPTION(() => {
@@ -566,10 +572,7 @@ class MathExerciseParser extends chevrotain.CstParser {
566
572
  this.CONSUME(RBracket);
567
573
  });
568
574
  this.MANY2(() => {
569
- this.OR2([
570
- { ALT: () => this.CONSUME2(Space) },
571
- { ALT: () => this.CONSUME2(Newline) }
572
- ]);
575
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
573
576
  });
574
577
  this.CONSUME(LBrace);
575
578
  this.MANY3(() => {
@@ -580,70 +583,47 @@ class MathExerciseParser extends chevrotain.CstParser {
580
583
  });
581
584
  this.CONSUME(RBrace);
582
585
  this.MANY4(() => {
583
- this.OR4([
584
- { ALT: () => this.CONSUME4(Space) },
585
- { ALT: () => this.CONSUME4(Newline) }
586
- ]);
586
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
587
587
  });
588
588
  });
589
589
  this.numberLine = this.RULE("numberLine", () => {
590
590
  this.MANY(() => {
591
- this.OR2([
592
- { ALT: () => this.CONSUME(Space) },
593
- { ALT: () => this.CONSUME(Newline) }
594
- ]);
591
+ this.OR2([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
595
592
  });
596
593
  this.CONSUME(NUMBER_LINE);
597
594
  this.CONSUME(LBracket);
598
595
  this.SUBRULE(this.parameterList);
599
596
  this.CONSUME(RBracket);
600
597
  this.MANY2(() => {
601
- this.OR3([
602
- { ALT: () => this.CONSUME2(Space) },
603
- { ALT: () => this.CONSUME2(Newline) }
604
- ]);
598
+ this.OR3([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
605
599
  });
606
600
  this.CONSUME(LBrace);
607
601
  this.MANY3(() => {
608
- this.OR4([
609
- { ALT: () => this.CONSUME3(Space) },
610
- { ALT: () => this.CONSUME3(Newline) }
611
- ]);
602
+ this.OR4([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
612
603
  });
613
604
  this.MANY4(() => {
614
- this.SUBRULE(this.position);
605
+ this.OR6([
606
+ { ALT: () => this.SUBRULE(this.position) },
607
+ { ALT: () => this.SUBRULE(this.counter) },
608
+ { ALT: () => this.SUBRULE(this.highlight) }
609
+ ]);
615
610
  });
616
611
  this.CONSUME(RBrace);
617
612
  this.MANY5(() => {
618
- this.OR5([
619
- { ALT: () => this.CONSUME4(Newline) },
620
- { ALT: () => this.CONSUME4(Space) }
621
- ]);
613
+ this.OR5([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
622
614
  });
623
615
  });
624
616
  this.position = this.RULE("position", () => {
625
- this.MANY(() => {
626
- this.OR([
627
- { ALT: () => this.CONSUME(Space) },
628
- { ALT: () => this.CONSUME(Newline) }
629
- ]);
630
- });
631
617
  this.CONSUME(POSITION);
632
618
  this.CONSUME(LBracket);
633
619
  this.SUBRULE(this.parameterList);
634
620
  this.CONSUME(RBracket);
635
621
  this.MANY2(() => {
636
- this.OR2([
637
- { ALT: () => this.CONSUME2(Space) },
638
- { ALT: () => this.CONSUME2(Newline) }
639
- ]);
622
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
640
623
  });
641
624
  this.CONSUME(LBrace);
642
625
  this.MANY3(() => {
643
- this.OR3([
644
- { ALT: () => this.CONSUME3(Space) },
645
- { ALT: () => this.CONSUME3(Newline) }
646
- ]);
626
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
647
627
  });
648
628
  this.MANY4(() => {
649
629
  this.OR4([
@@ -653,10 +633,25 @@ class MathExerciseParser extends chevrotain.CstParser {
653
633
  });
654
634
  this.CONSUME(RBrace);
655
635
  this.MANY5(() => {
656
- this.OR5([
657
- { ALT: () => this.CONSUME5(Space) },
658
- { ALT: () => this.CONSUME5(Newline) }
659
- ]);
636
+ this.OR5([{ ALT: () => this.CONSUME5(Space) }, { ALT: () => this.CONSUME5(Newline) }]);
637
+ });
638
+ });
639
+ this.counter = this.RULE("counter", () => {
640
+ this.CONSUME(COUNTER);
641
+ this.CONSUME(LBracket);
642
+ this.SUBRULE(this.parameterList);
643
+ this.CONSUME(RBracket);
644
+ this.MANY2(() => {
645
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
646
+ });
647
+ });
648
+ this.highlight = this.RULE("highlight", () => {
649
+ this.CONSUME(HIGHLIGHT);
650
+ this.CONSUME(LBracket);
651
+ this.SUBRULE(this.parameterList);
652
+ this.CONSUME(RBracket);
653
+ this.MANY2(() => {
654
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
660
655
  });
661
656
  });
662
657
  this.line = this.RULE("line", () => {
@@ -705,6 +700,8 @@ class MathExerciseParser extends chevrotain.CstParser {
705
700
  { ALT: () => this.SUBRULE(this.colorText) },
706
701
  // BOLD_TEXT (for bold text)
707
702
  { ALT: () => this.SUBRULE(this.boldText) },
703
+ // FRACTION (inline fraction widget)
704
+ { ALT: () => this.SUBRULE(this.fraction) },
708
705
  // W_S_I (inline input widget)
709
706
  { ALT: () => this.SUBRULE(this.inlineInput) }
710
707
  ]);
@@ -730,6 +727,12 @@ class MathExerciseParser extends chevrotain.CstParser {
730
727
  this.SUBRULE(this.parameterList);
731
728
  this.CONSUME(RBracket);
732
729
  });
730
+ this.fraction = this.RULE("fraction", () => {
731
+ this.CONSUME(FRACTION);
732
+ this.CONSUME(LBracket);
733
+ this.SUBRULE(this.parameterList);
734
+ this.CONSUME(RBracket);
735
+ });
733
736
  this.inlineInput = this.RULE("inlineInput", () => {
734
737
  this.OR([
735
738
  { ALT: () => this.CONSUME(W_S_I, { LABEL: "stringInput" }) },
@@ -753,28 +756,19 @@ class MathExerciseParser extends chevrotain.CstParser {
753
756
  this.SUBRULE(this.stringList);
754
757
  this.CONSUME(RBracket);
755
758
  this.MANY(() => {
756
- this.OR([
757
- { ALT: () => this.CONSUME(Newline) },
758
- { ALT: () => this.CONSUME(Space) }
759
- ]);
759
+ this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
760
760
  });
761
761
  });
762
762
  this.radioButtons = this.RULE("radioButtons", () => {
763
763
  this.MANY(() => {
764
- this.OR([
765
- { ALT: () => this.CONSUME(Newline) },
766
- { ALT: () => this.CONSUME(Space) }
767
- ]);
764
+ this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
768
765
  });
769
766
  this.CONSUME(RADIO_BUTTONS_OPTIONS);
770
767
  this.CONSUME(LBracket);
771
768
  this.SUBRULE(this.parameterList);
772
769
  this.CONSUME(RBracket);
773
770
  this.MANY2(() => {
774
- this.OR2([
775
- { ALT: () => this.CONSUME2(Newline) },
776
- { ALT: () => this.CONSUME2(Space) }
777
- ]);
771
+ this.OR2([{ ALT: () => this.CONSUME2(Newline) }, { ALT: () => this.CONSUME2(Space) }]);
778
772
  });
779
773
  });
780
774
  this.numericKeypad = this.RULE("numericKeypad", () => {
@@ -785,10 +779,7 @@ class MathExerciseParser extends chevrotain.CstParser {
785
779
  });
786
780
  this.CONSUME(RBracket);
787
781
  this.MANY(() => {
788
- this.OR([
789
- { ALT: () => this.CONSUME(Newline) },
790
- { ALT: () => this.CONSUME(Space) }
791
- ]);
782
+ this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
792
783
  });
793
784
  });
794
785
  this.validation = this.RULE("validation", () => {
@@ -924,13 +915,22 @@ const InlineInputWidgetSchema = zod.z.discriminatedUnion("inputType", [
924
915
  answer: zod.z.union([zod.z.string(), zod.z.number()]).optional()
925
916
  })
926
917
  ]);
918
+ const FractionInlineSchema = zod.z.object({
919
+ type: zod.z.literal(AST_TYPES.FRACTION),
920
+ name: zod.z.string(),
921
+ numerator: zod.z.number().optional(),
922
+ denominator: zod.z.number().optional(),
923
+ answer: zod.z.string().optional(),
924
+ simplified: zod.z.boolean().default(true)
925
+ });
927
926
  const InlineNodeSchema = zod.z.union([
928
927
  FreeTextInlineSchema,
929
928
  SpaceInlineSchema,
930
929
  HighlightedTextInlineSchema,
931
930
  ColorTextInlineSchema,
932
931
  BoldTextInlineSchema,
933
- InlineInputWidgetSchema
932
+ InlineInputWidgetSchema,
933
+ FractionInlineSchema
934
934
  ]);
935
935
  function createFreeText(value) {
936
936
  return FreeTextInlineSchema.parse({
@@ -983,6 +983,13 @@ function createNumericInput(name, digits, answer) {
983
983
  answer
984
984
  });
985
985
  }
986
+ function createFraction(name, options = {}) {
987
+ return FractionInlineSchema.parse({
988
+ type: AST_TYPES.FRACTION,
989
+ name,
990
+ ...options
991
+ });
992
+ }
986
993
  function isFreeText(value) {
987
994
  return FreeTextInlineSchema.safeParse(value).success;
988
995
  }
@@ -992,6 +999,9 @@ function isSpace(value) {
992
999
  function isInlineInput(value) {
993
1000
  return InlineInputWidgetSchema.safeParse(value).success;
994
1001
  }
1002
+ function isFraction(value) {
1003
+ return FractionInlineSchema.safeParse(value).success;
1004
+ }
995
1005
  function isInlineNode(value) {
996
1006
  return InlineNodeSchema.safeParse(value).success;
997
1007
  }
@@ -1023,7 +1033,7 @@ function isLineBlock(value) {
1023
1033
  }
1024
1034
  const NumberLinePositionSchema = zod.z.object({
1025
1035
  type: zod.z.literal(AST_TYPES.NUMBER_LINE_POSITION),
1026
- value: zod.z.number(),
1036
+ value: zod.z.number().finite({ message: "POSITION: value must be a finite number" }),
1027
1037
  inlines: zod.z.array(InlineNodeSchema).min(1)
1028
1038
  });
1029
1039
  function createNumberLinePosition(value, inlines) {
@@ -1043,10 +1053,50 @@ function createNumberLinePosition(value, inlines) {
1043
1053
  inlines
1044
1054
  });
1045
1055
  }
1056
+ const NumberLineCounterSchema = zod.z.object({
1057
+ type: zod.z.literal(AST_TYPES.NUMBER_LINE_COUNTER),
1058
+ min: zod.z.number().int().min(1, { message: "COUNTER: min must be >= 1" }),
1059
+ max: zod.z.number().int().positive({ message: "COUNTER: max must be > 0" }),
1060
+ default: zod.z.number().int().nonnegative().optional(),
1061
+ name: zod.z.string().optional()
1062
+ }).refine((d) => d.min <= d.max, {
1063
+ message: "COUNTER: min must be <= max"
1064
+ });
1065
+ function createNumberLineCounter(max, min, defaultValue2, name) {
1066
+ return NumberLineCounterSchema.parse({
1067
+ type: AST_TYPES.NUMBER_LINE_COUNTER,
1068
+ min: min ?? 1,
1069
+ max,
1070
+ ...defaultValue2 !== void 0 && { default: defaultValue2 },
1071
+ ...name !== void 0 && { name }
1072
+ });
1073
+ }
1074
+ const NumberLineHighlightSchema = zod.z.object({
1075
+ type: zod.z.literal(AST_TYPES.NUMBER_LINE_HIGHLIGHT),
1076
+ from: zod.z.number().finite({ message: "HIGHLIGHT: from must be a finite number" }),
1077
+ to: zod.z.number().finite({ message: "HIGHLIGHT: to must be a finite number" }),
1078
+ color: zod.z.enum(NUMBER_LINE_HIGHLIGHT_COLORS, {
1079
+ errorMap: () => ({
1080
+ message: `HIGHLIGHT: color must be one of ${NUMBER_LINE_HIGHLIGHT_COLORS.join(", ")}`
1081
+ })
1082
+ }).optional(),
1083
+ label: zod.z.string().optional()
1084
+ }).refine((d) => d.from < d.to, {
1085
+ message: "HIGHLIGHT: from must be less than to"
1086
+ });
1087
+ function createNumberLineHighlight(from, to, color, label) {
1088
+ return NumberLineHighlightSchema.parse({
1089
+ type: AST_TYPES.NUMBER_LINE_HIGHLIGHT,
1090
+ from,
1091
+ to,
1092
+ ...color !== void 0 && { color },
1093
+ ...label !== void 0 && { label }
1094
+ });
1095
+ }
1046
1096
  const NumberLineBlockSchema = zod.z.object({
1047
1097
  type: zod.z.literal(AST_TYPES.NUMBER_LINE_BLOCK),
1048
- min: zod.z.number(),
1049
- max: zod.z.number(),
1098
+ min: zod.z.number().finite({ message: "NUMBER_LINE: min must be a finite number" }),
1099
+ max: zod.z.number().finite({ message: "NUMBER_LINE: max must be a finite number" }),
1050
1100
  step: zod.z.number().positive({ message: "NUMBER_LINE: step must be greater than 0" }).default(1),
1051
1101
  minorTicks: zod.z.number().int().nonnegative({ message: "NUMBER_LINE: minorTicks must be >= 0" }).optional(),
1052
1102
  style: zod.z.enum(NUMBER_LINE_STYLES, {
@@ -1054,6 +1104,13 @@ const NumberLineBlockSchema = zod.z.object({
1054
1104
  message: `NUMBER_LINE: style must be one of ${NUMBER_LINE_STYLES.join(", ")}`
1055
1105
  })
1056
1106
  }).optional(),
1107
+ labelVisibility: zod.z.enum(NUMBER_LINE_LABELS, {
1108
+ errorMap: () => ({
1109
+ message: `NUMBER_LINE: labelVisibility must be one of ${NUMBER_LINE_LABELS.join(", ")}`
1110
+ })
1111
+ }).optional(),
1112
+ counter: NumberLineCounterSchema.optional(),
1113
+ highlights: zod.z.array(NumberLineHighlightSchema).optional(),
1057
1114
  positions: zod.z.array(NumberLinePositionSchema)
1058
1115
  }).refine((data) => data.min < data.max, {
1059
1116
  message: "NUMBER_LINE: min must be less than max"
@@ -1074,8 +1131,24 @@ const NumberLineBlockSchema = zod.z.object({
1074
1131
  }
1075
1132
  seen.add(pos.value);
1076
1133
  }
1134
+ for (const h of data.highlights ?? []) {
1135
+ if (h.from < data.min || h.to > data.max) {
1136
+ ctx.addIssue({
1137
+ code: zod.z.ZodIssueCode.custom,
1138
+ message: `NUMBER_LINE: HIGHLIGHT range [${h.from}, ${h.to}] must be within [${data.min}, ${data.max}]`
1139
+ });
1140
+ }
1141
+ }
1142
+ if (data.counter?.default !== void 0) {
1143
+ if (data.counter.default < data.counter.min || data.counter.default > data.counter.max) {
1144
+ ctx.addIssue({
1145
+ code: zod.z.ZodIssueCode.custom,
1146
+ message: `NUMBER_LINE: COUNTER default (${data.counter.default}) must be within [${data.counter.min}, ${data.counter.max}]`
1147
+ });
1148
+ }
1149
+ }
1077
1150
  });
1078
- function createNumberLineBlock(min, max, positions, step, minorTicks, style) {
1151
+ function createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights) {
1079
1152
  return NumberLineBlockSchema.parse({
1080
1153
  type: AST_TYPES.NUMBER_LINE_BLOCK,
1081
1154
  min,
@@ -1083,6 +1156,9 @@ function createNumberLineBlock(min, max, positions, step, minorTicks, style) {
1083
1156
  step,
1084
1157
  minorTicks,
1085
1158
  style,
1159
+ labelVisibility,
1160
+ counter,
1161
+ highlights,
1086
1162
  positions
1087
1163
  });
1088
1164
  }
@@ -1293,8 +1369,15 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1293
1369
  const step = params.step !== void 0 ? Number(params.step) : void 0;
1294
1370
  const minorTicks = params.minorTicks !== void 0 ? Number(params.minorTicks) : void 0;
1295
1371
  const style = params.style;
1372
+ const labelVisibility = params.labelVisibility;
1296
1373
  const positions = ctx.position ? ctx.position.map((p) => this.visit(p)) : [];
1297
- return createNumberLineBlock(min, max, positions, step, minorTicks, style);
1374
+ const rawCounters = ctx.counter;
1375
+ if (rawCounters && rawCounters.length > 1) {
1376
+ throw new Error("NUMBER_LINE: only one COUNTER block is allowed");
1377
+ }
1378
+ const counter = rawCounters ? this.visit(rawCounters) : void 0;
1379
+ const highlights = ctx.highlight ? ctx.highlight.map((h) => this.visit(h)) : [];
1380
+ return createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights);
1298
1381
  }
1299
1382
  position(ctx) {
1300
1383
  const params = this.visit(ctx.parameterList);
@@ -1305,6 +1388,28 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1305
1388
  const inlines = ctx.inlineElement ? ctx.inlineElement.map((elem) => this.visit(elem)) : [];
1306
1389
  return createNumberLinePosition(value, inlines);
1307
1390
  }
1391
+ counter(ctx) {
1392
+ const params = this.visit(ctx.parameterList);
1393
+ if (params.max === void 0) {
1394
+ throw new Error("COUNTER requires parameter: max");
1395
+ }
1396
+ const max = Number(params.max);
1397
+ const min = params.min !== void 0 ? Number(params.min) : void 0;
1398
+ const defaultValue2 = params.default !== void 0 ? Number(params.default) : void 0;
1399
+ const name = params.name;
1400
+ return createNumberLineCounter(max, min, defaultValue2, name);
1401
+ }
1402
+ highlight(ctx) {
1403
+ const params = this.visit(ctx.parameterList);
1404
+ if (params.from === void 0 || params.to === void 0) {
1405
+ throw new Error("HIGHLIGHT requires parameters: from and to");
1406
+ }
1407
+ const from = Number(params.from);
1408
+ const to = Number(params.to);
1409
+ const color = params.color;
1410
+ const label = params.label;
1411
+ return createNumberLineHighlight(from, to, color, label);
1412
+ }
1308
1413
  // ═══════════════════════════════════════════════════════════════════════════
1309
1414
  // Line Block
1310
1415
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1389,6 +1494,9 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1389
1494
  if (ctx.boldText) {
1390
1495
  return this.visit(ctx.boldText);
1391
1496
  }
1497
+ if (ctx.fraction) {
1498
+ return this.visit(ctx.fraction);
1499
+ }
1392
1500
  if (ctx.inlineInput) {
1393
1501
  return this.visit(ctx.inlineInput);
1394
1502
  }
@@ -1422,6 +1530,15 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1422
1530
  }
1423
1531
  return createBoldText(value, highlight);
1424
1532
  }
1533
+ fraction(ctx) {
1534
+ const params = this.visit(ctx.parameterList);
1535
+ const name = params.name;
1536
+ const numerator = params.numerator;
1537
+ const denominator = params.denominator;
1538
+ const answer = params.answer;
1539
+ const simplified = params.simplified !== void 0 ? params.simplified : void 0;
1540
+ return createFraction(name, { numerator, denominator, answer, simplified });
1541
+ }
1425
1542
  inlineInput(ctx) {
1426
1543
  const params = this.visit(ctx.parameterList);
1427
1544
  const name = params.name;
@@ -1635,17 +1752,36 @@ const TableItemSchema = zod.z.object({
1635
1752
  const NumberLinePositionItemSchema = zod.z.object({
1636
1753
  id: zod.z.string(),
1637
1754
  type: zod.z.literal(JSON_TYPES.NUMBER_LINE_POSITION),
1638
- value: zod.z.number(),
1639
- content: zod.z.array(zod.z.lazy(() => LayoutItemSchema))
1755
+ value: zod.z.number().finite(),
1756
+ content: zod.z.array(LayoutItemSchema)
1757
+ });
1758
+ const NumberLineCounterItemSchema = zod.z.object({
1759
+ id: zod.z.string(),
1760
+ type: zod.z.literal(JSON_TYPES.NUMBER_LINE_COUNTER),
1761
+ min: zod.z.number().int().min(1),
1762
+ max: zod.z.number().int().positive(),
1763
+ default: zod.z.number().int().nonnegative().optional(),
1764
+ name: zod.z.string().optional()
1765
+ });
1766
+ const NumberLineHighlightItemSchema = zod.z.object({
1767
+ id: zod.z.string(),
1768
+ type: zod.z.literal(JSON_TYPES.NUMBER_LINE_HIGHLIGHT),
1769
+ from: zod.z.number().finite(),
1770
+ to: zod.z.number().finite(),
1771
+ color: zod.z.enum(NUMBER_LINE_HIGHLIGHT_COLORS).optional(),
1772
+ label: zod.z.string().optional()
1640
1773
  });
1641
1774
  const NumberLineItemSchema = zod.z.object({
1642
1775
  id: zod.z.string(),
1643
1776
  type: zod.z.literal(JSON_TYPES.NUMBER_LINE),
1644
- min: zod.z.number(),
1645
- max: zod.z.number(),
1777
+ min: zod.z.number().finite(),
1778
+ max: zod.z.number().finite(),
1646
1779
  step: zod.z.number().positive(),
1647
1780
  minorTicks: zod.z.number().int().nonnegative().optional(),
1648
1781
  style: zod.z.enum(NUMBER_LINE_STYLES).optional(),
1782
+ labelVisibility: zod.z.enum(NUMBER_LINE_LABELS).optional(),
1783
+ counter: NumberLineCounterItemSchema.optional(),
1784
+ highlights: zod.z.array(NumberLineHighlightItemSchema).optional(),
1649
1785
  positions: zod.z.array(NumberLinePositionItemSchema)
1650
1786
  });
1651
1787
  const StringInputJSONSchema = zod.z.object({
@@ -1675,11 +1811,18 @@ const NumericKeypadJSONSchema = zod.z.object({
1675
1811
  includeOperators: zod.z.boolean().optional(),
1676
1812
  includeDecimal: zod.z.boolean().optional()
1677
1813
  });
1814
+ const FractionJSONSchema = zod.z.object({
1815
+ type: zod.z.literal(JSON_TYPES.FRACTION),
1816
+ numerator: zod.z.number().nullable(),
1817
+ denominator: zod.z.number().nullable(),
1818
+ simplified: zod.z.boolean()
1819
+ });
1678
1820
  const WidgetJSONSchema = zod.z.union([
1679
1821
  InlineInputJSONSchema,
1680
1822
  DragOptionsJSONSchema,
1681
1823
  RadioOptionsJSONSchema,
1682
- NumericKeypadJSONSchema
1824
+ NumericKeypadJSONSchema,
1825
+ FractionJSONSchema
1683
1826
  ]);
1684
1827
  const ValidationJSONSchema = zod.z.object({
1685
1828
  mode: zod.z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION]),
@@ -1719,7 +1862,7 @@ function isQuestionText(item) {
1719
1862
  function isTextItem$1(item) {
1720
1863
  return item.type === JSON_TYPES.TEXT;
1721
1864
  }
1722
- function isWidgetRef$1(item) {
1865
+ function isWidgetRef(item) {
1723
1866
  return item.type === JSON_TYPES.WIDGET_REF;
1724
1867
  }
1725
1868
  function isHighlightedText(item) {
@@ -1764,6 +1907,9 @@ function isRadioOptionsJSON(widget) {
1764
1907
  function isNumericKeypadJSON(widget) {
1765
1908
  return widget.type === JSON_TYPES.NUMERIC_KEYPAD;
1766
1909
  }
1910
+ function isFractionJSON(widget) {
1911
+ return widget.type === JSON_TYPES.FRACTION;
1912
+ }
1767
1913
  class LayoutIdGenerator {
1768
1914
  constructor() {
1769
1915
  this.counter = 0;
@@ -1828,6 +1974,17 @@ function toJSON(ast) {
1828
1974
  answers[inline.name] = inline.answer;
1829
1975
  }
1830
1976
  }
1977
+ if (inline.type === AST_TYPES.FRACTION) {
1978
+ widgets[inline.name] = {
1979
+ type: JSON_TYPES.FRACTION,
1980
+ numerator: inline.numerator ?? null,
1981
+ denominator: inline.denominator ?? null,
1982
+ simplified: inline.simplified ?? true
1983
+ };
1984
+ if (inline.answer !== void 0) {
1985
+ answers[inline.name] = inline.answer;
1986
+ }
1987
+ }
1831
1988
  }
1832
1989
  for (const block of ast.lines) {
1833
1990
  if (block.type === AST_TYPES.RADIO_OPTIONS) {
@@ -1892,16 +2049,42 @@ function transformNumberLine(numberLine, idGenerator) {
1892
2049
  content
1893
2050
  };
1894
2051
  });
2052
+ let counterItem;
2053
+ if (numberLine.counter !== void 0) {
2054
+ const c = numberLine.counter;
2055
+ counterItem = {
2056
+ id: idGenerator.generate(JSON_TYPES.NUMBER_LINE_COUNTER),
2057
+ type: JSON_TYPES.NUMBER_LINE_COUNTER,
2058
+ min: c.min,
2059
+ max: c.max,
2060
+ ...c.default !== void 0 && { default: c.default },
2061
+ ...c.name !== void 0 && { name: c.name }
2062
+ };
2063
+ }
2064
+ let highlights;
2065
+ if (numberLine.highlights !== void 0 && numberLine.highlights.length > 0) {
2066
+ highlights = numberLine.highlights.map((h) => ({
2067
+ id: idGenerator.generate(JSON_TYPES.NUMBER_LINE_HIGHLIGHT),
2068
+ type: JSON_TYPES.NUMBER_LINE_HIGHLIGHT,
2069
+ from: h.from,
2070
+ to: h.to,
2071
+ ...h.color !== void 0 && { color: h.color },
2072
+ ...h.label !== void 0 && { label: h.label }
2073
+ }));
2074
+ }
1895
2075
  const item = {
1896
2076
  id: idGenerator.generate(JSON_TYPES.NUMBER_LINE),
1897
2077
  type: JSON_TYPES.NUMBER_LINE,
1898
2078
  min: numberLine.min,
1899
2079
  max: numberLine.max,
1900
2080
  step: numberLine.step,
2081
+ ...numberLine.minorTicks !== void 0 && { minorTicks: numberLine.minorTicks },
2082
+ ...numberLine.style !== void 0 && { style: numberLine.style },
2083
+ ...numberLine.labelVisibility !== void 0 && { labelVisibility: numberLine.labelVisibility },
2084
+ ...counterItem !== void 0 && { counter: counterItem },
2085
+ ...highlights !== void 0 && { highlights },
1901
2086
  positions
1902
2087
  };
1903
- if (numberLine.minorTicks !== void 0) item.minorTicks = numberLine.minorTicks;
1904
- if (numberLine.style !== void 0) item.style = numberLine.style;
1905
2088
  return item;
1906
2089
  }
1907
2090
  function transformTable(table, idGenerator) {
@@ -1992,6 +2175,12 @@ function transformInline(inline, idGenerator) {
1992
2175
  type: JSON_TYPES.WIDGET_REF,
1993
2176
  widgetId: inline.name
1994
2177
  };
2178
+ case AST_TYPES.FRACTION:
2179
+ return {
2180
+ id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
2181
+ type: JSON_TYPES.WIDGET_REF,
2182
+ widgetId: inline.name
2183
+ };
1995
2184
  default:
1996
2185
  throw new Error(`Unknown inline type: ${inline.type}`);
1997
2186
  }
@@ -2024,6 +2213,50 @@ function extractVariables$1(expression, validVariables) {
2024
2213
  }
2025
2214
  return uniqueVars;
2026
2215
  }
2216
+ function normalize(value) {
2217
+ if (typeof value === "number") {
2218
+ return String(Math.round(value * 10 ** INTERNAL_PRECISION) / 10 ** INTERNAL_PRECISION);
2219
+ }
2220
+ const trimmed = String(value).trim().toLowerCase().replace(/^["']|["']$/g, "");
2221
+ const asNum = Number(trimmed);
2222
+ if (trimmed !== "" && !isNaN(asNum)) {
2223
+ return String(Math.round(asNum * 10 ** INTERNAL_PRECISION) / 10 ** INTERNAL_PRECISION);
2224
+ }
2225
+ return trimmed;
2226
+ }
2227
+ const NL_DRAG_PREFIX = "\0nl-drag\0";
2228
+ const isNLDragEncoded = (v) => v.startsWith(NL_DRAG_PREFIX);
2229
+ const encodeNLDragValue = (label, position) => `${NL_DRAG_PREFIX}${label}@${String(position)}`;
2230
+ function isNLDragCorrect(encoded, expectedLabel, expectedPosition) {
2231
+ const payload = encoded.slice(NL_DRAG_PREFIX.length);
2232
+ const atIdx = payload.lastIndexOf("@");
2233
+ const label = normalize(payload.slice(0, atIdx));
2234
+ const position = normalize(payload.slice(atIdx + 1));
2235
+ return label === normalize(expectedLabel) && position === normalize(expectedPosition);
2236
+ }
2237
+ const math$1 = mathjs.create(mathjs.all);
2238
+ function collectNumberLines(items) {
2239
+ const result = [];
2240
+ for (const item of items) {
2241
+ if (item.type === JSON_TYPES.NUMBER_LINE) result.push(item);
2242
+ else if (item.type === JSON_TYPES.GROUP) result.push(...collectNumberLines(item.items));
2243
+ }
2244
+ return result;
2245
+ }
2246
+ function buildPositionMap(exercise) {
2247
+ const map = {};
2248
+ for (const nl of collectNumberLines(exercise.layout)) {
2249
+ for (const pos of nl.positions) {
2250
+ for (const c of pos.content) {
2251
+ if (isWidgetRef(c)) {
2252
+ map[c.widgetId] = pos.value;
2253
+ break;
2254
+ }
2255
+ }
2256
+ }
2257
+ }
2258
+ return map;
2259
+ }
2027
2260
  function validateSimple(exercise, userInputs) {
2028
2261
  const details = {};
2029
2262
  let allCorrect = true;
@@ -2037,10 +2270,13 @@ function validateSimple(exercise, userInputs) {
2037
2270
  score: 0
2038
2271
  };
2039
2272
  }
2273
+ const positionMap = buildPositionMap(exercise);
2040
2274
  for (const [widgetId, expected] of Object.entries(answers)) {
2041
2275
  totalCount++;
2042
2276
  const userValue = userInputs[widgetId];
2043
- const isCorrect = areValuesEqual(userValue, expected);
2277
+ const widget = exercise.widgets[widgetId];
2278
+ const isFractionLenient = widget?.type === JSON_TYPES.FRACTION && widget.simplified === false;
2279
+ const isCorrect = isFractionLenient ? areFractionsEquivalent(userValue, expected) : areValuesEqual(userValue, expected, positionMap[widgetId]);
2044
2280
  details[widgetId] = {
2045
2281
  widgetId,
2046
2282
  userValue,
@@ -2068,19 +2304,23 @@ function validateSimple(exercise, userInputs) {
2068
2304
  score: totalCount > 0 ? correctCount / totalCount : 0
2069
2305
  };
2070
2306
  }
2071
- function areValuesEqual(userValue, expected) {
2072
- if (userValue === void 0 || userValue === null) {
2073
- return false;
2307
+ function areValuesEqual(userValue, expected, expectedPosition) {
2308
+ if (userValue === void 0 || userValue === null) return false;
2309
+ if (typeof userValue === "string" && isNLDragEncoded(userValue)) {
2310
+ if (expectedPosition === void 0) return false;
2311
+ return isNLDragCorrect(userValue, expected, expectedPosition);
2074
2312
  }
2075
- const normalizedUser = normalize(userValue);
2076
- const normalizedExpected = normalize(expected);
2077
- return normalizedUser === normalizedExpected;
2313
+ return normalize(userValue) === normalize(expected);
2078
2314
  }
2079
- function normalize(value) {
2080
- if (typeof value === "number") {
2081
- return String(Math.round(value * 1e10) / 1e10);
2315
+ function areFractionsEquivalent(user, expected) {
2316
+ if (user === void 0 || user === null) return false;
2317
+ try {
2318
+ const userFrac = math$1.fraction(String(user));
2319
+ const expectedFrac = math$1.fraction(String(expected));
2320
+ return math$1.equal(userFrac, expectedFrac);
2321
+ } catch {
2322
+ return areValuesEqual(user, expected);
2082
2323
  }
2083
- return String(value).trim().toLowerCase().replace(/^["']|["']$/g, "");
2084
2324
  }
2085
2325
  const math = mathjs.create(mathjs.all);
2086
2326
  const BLOCKED_FUNCTIONS = [
@@ -2235,7 +2475,7 @@ function validateExpression(exercise, userInputs) {
2235
2475
  hasInvalidInput = true;
2236
2476
  continue;
2237
2477
  }
2238
- const numValue = typeof rawValue === "number" ? rawValue : parseFloat(String(rawValue).trim());
2478
+ const numValue = typeof rawValue === "number" ? rawValue : parseFractionOrNumber(String(rawValue).trim());
2239
2479
  if (!Number.isFinite(numValue)) {
2240
2480
  details[varName] = {
2241
2481
  widgetId: varName,
@@ -2330,6 +2570,39 @@ function findValidCombination(expression, ranges) {
2330
2570
  }
2331
2571
  return null;
2332
2572
  }
2573
+ function parseFractionOrNumber(s) {
2574
+ const parts = s.split("/");
2575
+ if (parts.length === 2) {
2576
+ const num = parseFloat(parts[0]);
2577
+ const den = parseFloat(parts[1]);
2578
+ if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) {
2579
+ return num / den;
2580
+ }
2581
+ return NaN;
2582
+ }
2583
+ return parseFloat(s);
2584
+ }
2585
+ function preprocessFractionValues(exercise, userInputs) {
2586
+ const result = { ...userInputs };
2587
+ for (const [widgetId, widget] of Object.entries(exercise.widgets)) {
2588
+ if (widget.type !== JSON_TYPES.FRACTION) continue;
2589
+ const numKey = getFractionKey(widgetId, "num");
2590
+ const denKey = getFractionKey(widgetId, "den");
2591
+ const numEffective = widget.numerator !== null ? String(widget.numerator) : result[numKey] !== void 0 ? String(result[numKey]) : "";
2592
+ const denEffective = widget.denominator !== null ? String(widget.denominator) : result[denKey] !== void 0 ? String(result[denKey]) : "";
2593
+ if (numEffective !== "" && denEffective !== "") {
2594
+ result[widgetId] = `${numEffective}/${denEffective}`;
2595
+ }
2596
+ delete result[numKey];
2597
+ delete result[denKey];
2598
+ }
2599
+ for (const key2 of Object.keys(result)) {
2600
+ if (isFractionKey(key2)) {
2601
+ delete result[key2];
2602
+ }
2603
+ }
2604
+ return result;
2605
+ }
2333
2606
  function validate(exercise, userInputs) {
2334
2607
  if (!exercise.validation) {
2335
2608
  return {
@@ -2337,12 +2610,13 @@ function validate(exercise, userInputs) {
2337
2610
  details: {}
2338
2611
  };
2339
2612
  }
2613
+ const processedInputs = preprocessFractionValues(exercise, userInputs);
2340
2614
  const validatedExercise = exercise;
2341
2615
  switch (validatedExercise.validation.mode) {
2342
2616
  case VALIDATION_MODES.SIMPLE:
2343
- return validateSimple(validatedExercise, userInputs);
2617
+ return validateSimple(validatedExercise, processedInputs);
2344
2618
  case VALIDATION_MODES.EXPRESSION:
2345
- return validateExpression(validatedExercise, userInputs);
2619
+ return validateExpression(validatedExercise, processedInputs);
2346
2620
  default:
2347
2621
  return {
2348
2622
  isCorrect: false,
@@ -6412,6 +6686,7 @@ const InlineInput = ({
6412
6686
  widgetId,
6413
6687
  value,
6414
6688
  isDropped = false,
6689
+ validationStatus = null,
6415
6690
  inputType,
6416
6691
  length,
6417
6692
  digits,
@@ -6429,6 +6704,7 @@ const InlineInput = ({
6429
6704
  }, [handleChange]);
6430
6705
  const expectedLength = inputType === "numeric" ? digits : length;
6431
6706
  const dynamicWidth = `${(expectedLength || 5) * 0.8 + 1}em`;
6707
+ const statusClass = validationStatus ? ` widget--${validationStatus}` : "";
6432
6708
  return /* @__PURE__ */ jsxRuntime.jsx(
6433
6709
  Droppable,
6434
6710
  {
@@ -6441,7 +6717,7 @@ const InlineInput = ({
6441
6717
  /* @__PURE__ */ jsxRuntime.jsx(
6442
6718
  "button",
6443
6719
  {
6444
- className: "inline-input-chip",
6720
+ className: `inline-input-chip${statusClass}`,
6445
6721
  onClick: () => handleChange("", false),
6446
6722
  title: "Click to remove",
6447
6723
  children: value
@@ -6454,7 +6730,7 @@ const InlineInput = ({
6454
6730
  {
6455
6731
  id: widgetId + "-input",
6456
6732
  type: "text",
6457
- className: "inline-input",
6733
+ className: `inline-input${statusClass}`,
6458
6734
  value,
6459
6735
  onChange: (e) => handleChange(e.target.value, false),
6460
6736
  onFocus: () => onFocus?.(widgetId),
@@ -6531,18 +6807,83 @@ const NumericKeypad = ({
6531
6807
  includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("÷"), children: "÷" })
6532
6808
  ] });
6533
6809
  };
6810
+ const FractionInput = ({
6811
+ widgetId,
6812
+ numerator,
6813
+ denominator,
6814
+ values,
6815
+ droppedInputs,
6816
+ validationStatus = null,
6817
+ onChange,
6818
+ onFocus
6819
+ }) => {
6820
+ const numKey = getFractionKey(widgetId, "num");
6821
+ const denKey = getFractionKey(widgetId, "den");
6822
+ const numValue = values[numKey] ?? "";
6823
+ const denValue = values[denKey] ?? "";
6824
+ const statusClass = validationStatus ? ` widget--${validationStatus}` : "";
6825
+ const handleDrop = React.useCallback((subKey) => (item) => {
6826
+ if (item.type === "drag-option") {
6827
+ const data = item.data;
6828
+ onChange(subKey, data.value, true, String(item.id));
6829
+ }
6830
+ }, [onChange]);
6831
+ const renderSlot = (subKey, fixedValue, currentValue, label) => {
6832
+ if (fixedValue !== null) {
6833
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-fixed", children: fixedValue });
6834
+ }
6835
+ const isDroppedHere = droppedInputs.has(subKey);
6836
+ return /* @__PURE__ */ jsxRuntime.jsx(
6837
+ Droppable,
6838
+ {
6839
+ id: subKey,
6840
+ accepts: ["drag-option"],
6841
+ onDrop: handleDrop(subKey),
6842
+ className: "fraction-slot-droppable",
6843
+ children: isDroppedHere && currentValue !== "" ? /* @__PURE__ */ jsxRuntime.jsx(
6844
+ "button",
6845
+ {
6846
+ className: `inline-input-chip fraction-chip${statusClass}`,
6847
+ onClick: () => onChange(subKey, "", false),
6848
+ title: "Click to remove",
6849
+ children: currentValue
6850
+ }
6851
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
6852
+ "input",
6853
+ {
6854
+ id: `${subKey}-input`,
6855
+ type: "text",
6856
+ className: `inline-input fraction-input${statusClass}`,
6857
+ value: currentValue,
6858
+ onChange: (e) => onChange(subKey, e.target.value, false),
6859
+ onFocus: () => onFocus?.(subKey),
6860
+ maxLength: 4,
6861
+ "aria-label": label
6862
+ }
6863
+ )
6864
+ }
6865
+ );
6866
+ };
6867
+ return /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fraction", role: "math", "aria-label": `fraction ${widgetId}`, children: [
6868
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-numerator", children: renderSlot(numKey, numerator, numValue, "numerator") }),
6869
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-bar", "aria-hidden": "true" }),
6870
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-denominator", children: renderSlot(denKey, denominator, denValue, "denominator") })
6871
+ ] });
6872
+ };
6534
6873
  const RadioOptions = ({
6535
6874
  name,
6536
6875
  options,
6537
6876
  value,
6877
+ validationStatus = null,
6538
6878
  onChange
6539
6879
  }) => {
6540
6880
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "radio-options", children: options.map((option, idx) => {
6541
6881
  const isSelected = value === option;
6882
+ const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
6542
6883
  return /* @__PURE__ */ jsxRuntime.jsx(
6543
6884
  "button",
6544
6885
  {
6545
- className: `radio-option${isSelected ? " selected" : ""}`,
6886
+ className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
6546
6887
  onClick: () => onChange(name, option),
6547
6888
  type: "button",
6548
6889
  children: option
@@ -6563,11 +6904,17 @@ function isRadioOptionsWidget(widget) {
6563
6904
  function isNumericKeypadWidget(widget) {
6564
6905
  return widget.type === "numeric-keypad";
6565
6906
  }
6907
+ function isFractionWidget(widget) {
6908
+ return widget.type === "fraction";
6909
+ }
6566
6910
  const WidgetRenderer = ({
6567
6911
  widget,
6568
6912
  widgetId,
6569
6913
  value,
6914
+ values = {},
6570
6915
  isDropped = false,
6916
+ validationStatus = null,
6917
+ droppedInputs = /* @__PURE__ */ new Set(),
6571
6918
  onChange,
6572
6919
  onInputFocus,
6573
6920
  onKeypadChange
@@ -6579,6 +6926,7 @@ const WidgetRenderer = ({
6579
6926
  widgetId,
6580
6927
  value,
6581
6928
  isDropped,
6929
+ validationStatus,
6582
6930
  inputType: widget.inputType,
6583
6931
  length: widget.inputType === "string" ? widget.length : void 0,
6584
6932
  digits: widget.inputType === "numeric" ? widget.digits : void 0,
@@ -6604,6 +6952,7 @@ const WidgetRenderer = ({
6604
6952
  name: widgetId,
6605
6953
  options: widget.options,
6606
6954
  value,
6955
+ validationStatus,
6607
6956
  onChange
6608
6957
  }
6609
6958
  );
@@ -6620,9 +6969,27 @@ const WidgetRenderer = ({
6620
6969
  }
6621
6970
  );
6622
6971
  }
6972
+ if (isFractionWidget(widget)) {
6973
+ return /* @__PURE__ */ jsxRuntime.jsx(
6974
+ FractionInput,
6975
+ {
6976
+ widgetId,
6977
+ numerator: widget.numerator,
6978
+ denominator: widget.denominator,
6979
+ values,
6980
+ droppedInputs,
6981
+ validationStatus,
6982
+ onChange,
6983
+ onFocus: onInputFocus
6984
+ }
6985
+ );
6986
+ }
6623
6987
  return null;
6624
6988
  };
6625
- const cleanFloat = (n) => parseFloat(n.toFixed(10));
6989
+ const EPSILON = 10 ** -INTERNAL_PRECISION;
6990
+ const cleanFloat = (n) => parseFloat(n.toFixed(INTERNAL_PRECISION));
6991
+ const roundTick = (n) => parseFloat(n.toFixed(DISPLAY_PRECISION));
6992
+ const almostEqual = (a, b) => Math.abs(a - b) <= EPSILON;
6626
6993
  const toPercent = (value, min, max) => {
6627
6994
  const raw = SIDE_PAD + (value - min) / (max - min) * (100 - 2 * SIDE_PAD);
6628
6995
  return Math.max(SIDE_PAD, Math.min(100 - SIDE_PAD, raw));
@@ -6638,19 +7005,24 @@ const getMajorTicks = (min, max, step) => {
6638
7005
  const count = Math.floor((max - min) / step);
6639
7006
  const ticks = Array.from({ length: count + 1 }, (_, i) => cleanFloat(min + i * step));
6640
7007
  const lastTick = ticks[ticks.length - 1];
6641
- if (Math.abs(lastTick - max) > 1e-9) ticks.push(max);
7008
+ if (!almostEqual(lastTick, max)) {
7009
+ ticks.push(max);
7010
+ } else {
7011
+ ticks[ticks.length - 1] = max;
7012
+ }
6642
7013
  return ticks;
6643
7014
  };
6644
7015
  const getMinorTickValues = (majorTicks, minorTicks) => {
6645
- if (minorTicks <= 0) return [];
7016
+ if (minorTicks <= 0 || majorTicks.length < 2) return [];
6646
7017
  const result = [];
6647
- majorTicks.slice(0, -1).forEach((tick, idx) => {
6648
- const next = majorTicks[idx + 1];
6649
- const interval = next - tick;
7018
+ for (let i = 0; i < majorTicks.length - 1; i++) {
7019
+ const from = majorTicks[i];
7020
+ const to = majorTicks[i + 1];
7021
+ const interval = to - from;
6650
7022
  for (let j = 1; j <= minorTicks; j++) {
6651
- result.push(cleanFloat(tick + j / (minorTicks + 1) * interval));
7023
+ result.push(cleanFloat(from + j / (minorTicks + 1) * interval));
6652
7024
  }
6653
- });
7025
+ }
6654
7026
  return result;
6655
7027
  };
6656
7028
  const getSnapTicks = (majorTicks, minorTicks) => [
@@ -6659,19 +7031,41 @@ const getSnapTicks = (majorTicks, minorTicks) => [
6659
7031
  ];
6660
7032
  const snapToNearest = (raw, ticks) => {
6661
7033
  if (ticks.length === 0) return cleanFloat(raw);
6662
- const snapped = ticks.reduce(
6663
- (best, t) => Math.abs(t - raw) < Math.abs(best - raw) ? t : best
6664
- );
6665
- return cleanFloat(snapped);
7034
+ return ticks.reduce((best, t) => Math.abs(t - raw) < Math.abs(best - raw) ? t : best);
6666
7035
  };
6667
- const isWidgetRef = (item) => item.type === JSON_TYPES.WIDGET_REF;
6668
7036
  const getPositionWidgetId = (content) => {
6669
7037
  for (const item of content) {
6670
7038
  if (isWidgetRef(item)) return item.widgetId;
6671
7039
  }
6672
7040
  return null;
6673
7041
  };
6674
- const PlacedChip = ({ widgetId, label, leftPercent, onRemove }) => {
7042
+ const toHighlightColor = (color) => `var(--nl-highlight-${color})`;
7043
+ const getBandGeometry = (h, min, max) => {
7044
+ const leftPct = toPercent(Math.min(h.from, h.to), min, max);
7045
+ const rightPct = toPercent(Math.max(h.from, h.to), min, max);
7046
+ return { leftPct, widthPct: rightPct - leftPct };
7047
+ };
7048
+ const HighlightBands = ({ highlights, min, max }) => {
7049
+ if (!highlights?.length) return null;
7050
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: highlights.map((h) => {
7051
+ const { leftPct, widthPct } = getBandGeometry(h, min, max);
7052
+ if (widthPct <= 0) return null;
7053
+ return /* @__PURE__ */ jsxRuntime.jsx(
7054
+ "div",
7055
+ {
7056
+ className: "number-line-highlight-band",
7057
+ "aria-hidden": "true",
7058
+ style: {
7059
+ left: `${leftPct}%`,
7060
+ width: `${widthPct}%`,
7061
+ ...h.color && { "--nl-highlight-color": toHighlightColor(h.color) }
7062
+ }
7063
+ },
7064
+ h.id
7065
+ );
7066
+ }) });
7067
+ };
7068
+ const PlacedChip = ({ widgetId, label, leftPercent, isLanding, onRemove }) => {
6675
7069
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
6676
7070
  id: widgetId,
6677
7071
  data: { type: "placed-chip", widgetId, label }
@@ -6679,23 +7073,49 @@ const PlacedChip = ({ widgetId, label, leftPercent, onRemove }) => {
6679
7073
  return /* @__PURE__ */ jsxRuntime.jsx(
6680
7074
  "div",
6681
7075
  {
6682
- className: "number-line-placed-chip",
6683
- style: { left: `${leftPercent}%`, opacity: isDragging ? 0.3 : 1 },
6684
- children: /* @__PURE__ */ jsxRuntime.jsx(
6685
- "button",
6686
- {
6687
- ref: setNodeRef,
6688
- className: "number-line-placed-chip-btn",
6689
- title: "Drag to move · Click to remove",
6690
- onClick: onRemove,
6691
- ...listeners,
6692
- ...attributes,
6693
- children: label
6694
- }
6695
- )
7076
+ className: `number-line-placed-chip${isLanding ? " nl-chip-landing" : ""}${isDragging ? " nl-chip-dragging" : ""}`,
7077
+ style: { left: `${leftPercent}%` },
7078
+ children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "number-line-placed-chip-inner", children: [
7079
+ /* @__PURE__ */ jsxRuntime.jsx(
7080
+ "button",
7081
+ {
7082
+ ref: setNodeRef,
7083
+ className: "number-line-placed-chip-btn",
7084
+ "aria-label": `${label}, drag to move`,
7085
+ ...listeners,
7086
+ ...attributes,
7087
+ children: label
7088
+ }
7089
+ ),
7090
+ /* @__PURE__ */ jsxRuntime.jsx(
7091
+ "button",
7092
+ {
7093
+ type: "button",
7094
+ className: "number-line-placed-chip-remove",
7095
+ "aria-label": "Remove",
7096
+ onClick: onRemove,
7097
+ children: "✕"
7098
+ }
7099
+ )
7100
+ ] })
6696
7101
  }
6697
7102
  );
6698
7103
  };
7104
+ function findNearestFreeWidgetSlot(positions, axisValue, values, placedChips, excludeWid) {
7105
+ let best;
7106
+ let bestDist = Infinity;
7107
+ for (const pos of positions) {
7108
+ const wid = getPositionWidgetId(pos.content);
7109
+ if (!wid) continue;
7110
+ if (wid !== excludeWid && (values[wid] || placedChips.has(wid))) continue;
7111
+ const dist = Math.abs(pos.value - axisValue);
7112
+ if (dist < bestDist) {
7113
+ bestDist = dist;
7114
+ best = pos;
7115
+ }
7116
+ }
7117
+ return best;
7118
+ }
6699
7119
  const FreeDragAxis = ({
6700
7120
  axisDropId,
6701
7121
  min,
@@ -6706,17 +7126,51 @@ const FreeDragAxis = ({
6706
7126
  showArrowRight,
6707
7127
  positions,
6708
7128
  values,
6709
- onChange
7129
+ onChange,
7130
+ highlights,
7131
+ snapKey
6710
7132
  }) => {
6711
7133
  const axisRef = React.useRef(null);
6712
7134
  const snapTicks = React.useMemo(
6713
7135
  () => getSnapTicks(majorTicks, minorTicks),
6714
7136
  [majorTicks, minorTicks]
6715
7137
  );
6716
- const [dropLabels, setDropLabels] = React.useState(/* @__PURE__ */ new Map());
6717
- const [dropSources, setDropSources] = React.useState(/* @__PURE__ */ new Map());
7138
+ const minorValues = React.useMemo(
7139
+ () => getMinorTickValues(majorTicks, minorTicks),
7140
+ [majorTicks, minorTicks]
7141
+ );
7142
+ const [placedChips, setPlacedChips] = React.useState(/* @__PURE__ */ new Map());
7143
+ const [hoverSnap, setHoverSnap] = React.useState(null);
7144
+ const [landingChips, setLandingChips] = React.useState(/* @__PURE__ */ new Set());
7145
+ const landingTimersRef = React.useRef(/* @__PURE__ */ new Map());
7146
+ React.useEffect(() => () => landingTimersRef.current.forEach(clearTimeout), []);
7147
+ const onChangeRef = React.useRef(onChange);
7148
+ const placedChipsRef = React.useRef(placedChips);
6718
7149
  React.useEffect(() => {
6719
- setDropLabels((prev) => {
7150
+ onChangeRef.current = onChange;
7151
+ }, [onChange]);
7152
+ React.useEffect(() => {
7153
+ placedChipsRef.current = placedChips;
7154
+ }, [placedChips]);
7155
+ const isFirstRender = React.useRef(true);
7156
+ React.useEffect(() => {
7157
+ if (isFirstRender.current) {
7158
+ isFirstRender.current = false;
7159
+ return;
7160
+ }
7161
+ for (const [widgetId, chip] of placedChipsRef.current) {
7162
+ onChangeRef.current(widgetId, "", false, chip.sourceId);
7163
+ }
7164
+ landingTimersRef.current.forEach(clearTimeout);
7165
+ landingTimersRef.current.clear();
7166
+ const empty = /* @__PURE__ */ new Map();
7167
+ placedChipsRef.current = empty;
7168
+ setPlacedChips(empty);
7169
+ setHoverSnap(null);
7170
+ setLandingChips(/* @__PURE__ */ new Set());
7171
+ }, [snapKey]);
7172
+ React.useEffect(() => {
7173
+ setPlacedChips((prev) => {
6720
7174
  let changed = false;
6721
7175
  const next = new Map(prev);
6722
7176
  for (const [wid] of next) {
@@ -6725,21 +7179,13 @@ const FreeDragAxis = ({
6725
7179
  changed = true;
6726
7180
  }
6727
7181
  }
6728
- return changed ? next : prev;
6729
- });
6730
- setDropSources((prev) => {
6731
- let changed = false;
6732
- const next = new Map(prev);
6733
- for (const [wid] of next) {
6734
- if (!values[wid]) {
6735
- next.delete(wid);
6736
- changed = true;
6737
- }
7182
+ if (changed) {
7183
+ placedChipsRef.current = next;
6738
7184
  }
6739
7185
  return changed ? next : prev;
6740
7186
  });
6741
7187
  }, [values]);
6742
- const { setNodeRef, isOver } = useDroppable({ id: axisDropId });
7188
+ const { setNodeRef } = useDroppable({ id: axisDropId });
6743
7189
  const mergedRef = React.useCallback((node) => {
6744
7190
  axisRef.current = node;
6745
7191
  setNodeRef(node);
@@ -6747,91 +7193,163 @@ const FreeDragAxis = ({
6747
7193
  const snapFromEvent = React.useCallback((translated) => {
6748
7194
  if (!axisRef.current) return null;
6749
7195
  const axisRect = axisRef.current.getBoundingClientRect();
7196
+ if (axisRect.width === 0) return null;
6750
7197
  const chipCenterX = translated.left + translated.width / 2;
6751
7198
  const raw = pixelToValue(chipCenterX, axisRect.left, axisRect.width, min, max);
6752
7199
  return snapToNearest(raw, snapTicks);
6753
7200
  }, [min, max, snapTicks]);
7201
+ const isTickOccupied = React.useCallback(
7202
+ (tick, excludeWid) => [...placedChipsRef.current.entries()].some(
7203
+ ([wid, c]) => wid !== excludeWid && almostEqual(c.position, tick)
7204
+ ),
7205
+ []
7206
+ // reads placedChipsRef (a ref — stable, no dep needed)
7207
+ );
7208
+ const findNearestFreeWidgetSlotCb = React.useCallback(
7209
+ (axisValue, excludeWid) => findNearestFreeWidgetSlot(positions, axisValue, values, placedChipsRef.current, excludeWid),
7210
+ [positions, values]
7211
+ );
7212
+ const triggerLanding = React.useCallback((widgetId) => {
7213
+ setLandingChips((prev) => new Set(prev).add(widgetId));
7214
+ clearTimeout(landingTimersRef.current.get(widgetId));
7215
+ landingTimersRef.current.set(widgetId, setTimeout(() => {
7216
+ setLandingChips((prev) => {
7217
+ const s = new Set(prev);
7218
+ s.delete(widgetId);
7219
+ return s;
7220
+ });
7221
+ landingTimersRef.current.delete(widgetId);
7222
+ }, 300));
7223
+ }, []);
6754
7224
  useDndMonitor({
7225
+ onDragMove(event) {
7226
+ if (!event.over || event.over.id !== axisDropId) {
7227
+ setHoverSnap(null);
7228
+ return;
7229
+ }
7230
+ const translated = event.active.rect.current.translated;
7231
+ if (!translated) return;
7232
+ setHoverSnap(snapFromEvent(translated));
7233
+ },
7234
+ onDragCancel() {
7235
+ setHoverSnap(null);
7236
+ },
6755
7237
  onDragEnd(event) {
7238
+ setHoverSnap(null);
6756
7239
  if (!event.over || event.over.id !== axisDropId) return;
6757
7240
  if (!axisRef.current) return;
6758
7241
  const translated = event.active.rect.current.translated;
6759
7242
  if (!translated) return;
6760
- const placed = snapFromEvent(translated);
6761
- if (placed === null) return;
7243
+ const rawPlaced = snapFromEvent(translated);
7244
+ if (rawPlaced === null) {
7245
+ if (process.env.NODE_ENV !== "production") {
7246
+ console.warn("[FreeDragAxis] snap calculation failed — axis may not be laid out yet");
7247
+ }
7248
+ return;
7249
+ }
7250
+ const placed = cleanFloat(rawPlaced);
6762
7251
  const data = event.active.data.current;
6763
- if (data?.type === "placed-chip") {
6764
- const widgetId = data.widgetId;
7252
+ if (!data) return;
7253
+ if (data.type === "placed-chip") {
7254
+ const oldWidgetId = data.widgetId;
6765
7255
  const label = data.label;
6766
- const originalSourceId = dropSources.get(widgetId) ?? widgetId;
6767
- onChange(widgetId, String(placed), true, originalSourceId);
6768
- setDropLabels((prev) => new Map(prev).set(widgetId, label));
7256
+ const sourceId2 = placedChipsRef.current.get(oldWidgetId)?.sourceId ?? oldWidgetId;
7257
+ if (isTickOccupied(placed, oldWidgetId)) return;
7258
+ const targetPos2 = findNearestFreeWidgetSlotCb(placed, oldWidgetId);
7259
+ if (!targetPos2) return;
7260
+ const newWidgetId = getPositionWidgetId(targetPos2.content);
7261
+ if (!newWidgetId) return;
7262
+ if (newWidgetId !== oldWidgetId) {
7263
+ onChangeRef.current(oldWidgetId, "", false, sourceId2);
7264
+ }
7265
+ onChangeRef.current(newWidgetId, encodeNLDragValue(label, placed), true, sourceId2);
7266
+ setPlacedChips((prev) => {
7267
+ const next = new Map(prev);
7268
+ if (newWidgetId !== oldWidgetId) next.delete(oldWidgetId);
7269
+ next.set(newWidgetId, { label, position: placed, sourceId: sourceId2 });
7270
+ placedChipsRef.current = next;
7271
+ return next;
7272
+ });
7273
+ triggerLanding(newWidgetId);
6769
7274
  return;
6770
7275
  }
6771
- const draggedItem = event.active.data.current;
6772
- if (draggedItem?.type !== "drag-option") return;
6773
- const targetPos = positions.find((pos) => {
6774
- const wid = getPositionWidgetId(pos.content);
6775
- return wid && !values[wid];
6776
- });
7276
+ if (data.type !== "drag-option") return;
7277
+ if (isTickOccupied(placed)) return;
7278
+ const targetPos = findNearestFreeWidgetSlotCb(placed);
6777
7279
  if (!targetPos) return;
6778
7280
  const targetWidgetId = getPositionWidgetId(targetPos.content);
6779
7281
  if (!targetWidgetId) return;
6780
- const chipLabel = draggedItem.data.value;
6781
- const sourceOptId = String(event.active.id);
6782
- onChange(targetWidgetId, String(placed), true, sourceOptId);
6783
- setDropLabels((prev) => new Map(prev).set(targetWidgetId, chipLabel));
6784
- setDropSources((prev) => new Map(prev).set(targetWidgetId, sourceOptId));
7282
+ const chipLabel = data.data.value;
7283
+ const sourceId = String(event.active.id);
7284
+ onChangeRef.current(targetWidgetId, encodeNLDragValue(chipLabel, placed), true, sourceId);
7285
+ setPlacedChips((prev) => {
7286
+ const next = new Map(prev).set(targetWidgetId, { label: chipLabel, position: placed, sourceId });
7287
+ placedChipsRef.current = next;
7288
+ return next;
7289
+ });
7290
+ triggerLanding(targetWidgetId);
6785
7291
  }
6786
7292
  });
6787
7293
  return /* @__PURE__ */ jsxRuntime.jsxs(
6788
7294
  "div",
6789
7295
  {
6790
7296
  ref: mergedRef,
6791
- className: `number-line-axis-row nl-axis-drag-mode${isOver ? " nl-axis-drag-over" : ""}`,
7297
+ className: "number-line-axis-row nl-axis-drag-mode",
6792
7298
  children: [
6793
7299
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-track" }),
6794
- showArrowLeft && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-left" }),
6795
- showArrowRight && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-right" }),
6796
- getMinorTickValues(majorTicks, minorTicks).map((v) => /* @__PURE__ */ jsxRuntime.jsx(
7300
+ showArrowLeft && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-left", "aria-hidden": "true" }),
7301
+ showArrowRight && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-right", "aria-hidden": "true" }),
7302
+ /* @__PURE__ */ jsxRuntime.jsx(HighlightBands, { highlights, min, max }),
7303
+ minorValues.map((v) => /* @__PURE__ */ jsxRuntime.jsx(
6797
7304
  "div",
6798
7305
  {
6799
- className: "number-line-minor-tick",
7306
+ className: `number-line-minor-tick${hoverSnap === v ? " nl-tick-hover" : ""}`,
6800
7307
  style: { left: `${toPercent(v, min, max)}%` }
6801
7308
  },
6802
- `minor-${v}`
7309
+ `minor-${cleanFloat(v)}`
6803
7310
  )),
6804
7311
  majorTicks.map((tick) => /* @__PURE__ */ jsxRuntime.jsx(
6805
7312
  "div",
6806
7313
  {
6807
- className: "number-line-major-tick",
7314
+ className: `number-line-major-tick${hoverSnap === tick ? " nl-tick-hover" : ""}`,
6808
7315
  style: { left: `${toPercent(tick, min, max)}%` }
6809
7316
  },
6810
- `major-${tick}`
7317
+ `major-${cleanFloat(tick)}`
7318
+ )),
7319
+ hoverSnap !== null && /* @__PURE__ */ jsxRuntime.jsx(
7320
+ "div",
7321
+ {
7322
+ className: "nl-snap-preview",
7323
+ style: { left: `${toPercent(hoverSnap, min, max)}%` },
7324
+ "aria-hidden": "true"
7325
+ }
7326
+ ),
7327
+ positions.filter((pos) => getPositionWidgetId(pos.content) === null).map((pos) => /* @__PURE__ */ jsxRuntime.jsx(
7328
+ "div",
7329
+ {
7330
+ className: "number-line-position-dot",
7331
+ style: { left: `${toPercent(pos.value, min, max)}%` }
7332
+ },
7333
+ `dot-${pos.id}`
6811
7334
  )),
6812
7335
  positions.map((pos) => {
6813
7336
  const wid = getPositionWidgetId(pos.content);
6814
- const dropPos = wid ? parseFloat(values[wid] ?? "") : NaN;
6815
- const label = wid ? dropLabels.get(wid) : void 0;
6816
- if (!wid || isNaN(dropPos) || !label) return null;
7337
+ const chip = wid ? placedChips.get(wid) : void 0;
7338
+ if (!wid || !chip) return null;
6817
7339
  return /* @__PURE__ */ jsxRuntime.jsx(
6818
7340
  PlacedChip,
6819
7341
  {
6820
7342
  widgetId: wid,
6821
- label,
6822
- leftPercent: toPercent(dropPos, min, max),
7343
+ label: chip.label,
7344
+ leftPercent: toPercent(chip.position, min, max),
7345
+ isLanding: landingChips.has(wid),
6823
7346
  onRemove: () => {
6824
- const sourceId = dropSources.get(wid);
6825
- onChange(wid, "", false, sourceId);
6826
- setDropLabels((prev) => {
6827
- const m = new Map(prev);
6828
- m.delete(wid);
6829
- return m;
6830
- });
6831
- setDropSources((prev) => {
6832
- const m = new Map(prev);
6833
- m.delete(wid);
6834
- return m;
7347
+ onChangeRef.current(wid, "", false, chip.sourceId);
7348
+ setPlacedChips((prev) => {
7349
+ const next = new Map(prev);
7350
+ next.delete(wid);
7351
+ placedChipsRef.current = next;
7352
+ return next;
6835
7353
  });
6836
7354
  }
6837
7355
  },
@@ -6842,35 +7360,194 @@ const FreeDragAxis = ({
6842
7360
  }
6843
7361
  );
6844
7362
  };
7363
+ const AxisTicks = ({
7364
+ min,
7365
+ max,
7366
+ majorTicks,
7367
+ minorValues,
7368
+ showArrowLeft,
7369
+ showArrowRight,
7370
+ highlights,
7371
+ positions
7372
+ }) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "number-line-axis-row", role: "presentation", children: [
7373
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-track", "aria-hidden": "true" }),
7374
+ showArrowLeft && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-left", "aria-hidden": "true" }),
7375
+ showArrowRight && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-right", "aria-hidden": "true" }),
7376
+ /* @__PURE__ */ jsxRuntime.jsx(HighlightBands, { highlights, min, max }),
7377
+ minorValues.map((v) => /* @__PURE__ */ jsxRuntime.jsx(
7378
+ "div",
7379
+ {
7380
+ className: "number-line-minor-tick",
7381
+ "aria-hidden": "true",
7382
+ style: { left: `${toPercent(v, min, max)}%` }
7383
+ },
7384
+ `minor-${v}`
7385
+ )),
7386
+ majorTicks.map((tick) => /* @__PURE__ */ jsxRuntime.jsx(
7387
+ "div",
7388
+ {
7389
+ className: "number-line-major-tick",
7390
+ "aria-hidden": "true",
7391
+ style: { left: `${toPercent(tick, min, max)}%` }
7392
+ },
7393
+ `major-${tick}`
7394
+ )),
7395
+ positions.map((pos) => /* @__PURE__ */ jsxRuntime.jsx(
7396
+ "div",
7397
+ {
7398
+ className: "number-line-position-dot",
7399
+ "aria-hidden": "true",
7400
+ style: { left: `${toPercent(pos.value, min, max)}%` }
7401
+ },
7402
+ `dot-${pos.id}`
7403
+ ))
7404
+ ] });
7405
+ const isLabelVisible = (tick, min, max, labelVisibility) => {
7406
+ switch (labelVisibility) {
7407
+ case "none":
7408
+ return false;
7409
+ case "min-only":
7410
+ return tick === roundTick(min);
7411
+ case "max-only":
7412
+ return tick === roundTick(max);
7413
+ case "extremes":
7414
+ return tick === roundTick(min) || tick === roundTick(max);
7415
+ default:
7416
+ return true;
7417
+ }
7418
+ };
7419
+ const AxisLabels = ({
7420
+ majorTicks,
7421
+ min,
7422
+ max,
7423
+ labelVisibility,
7424
+ inputTickSet
7425
+ }) => /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-labels-row", "aria-hidden": "true", children: majorTicks.map((tick) => {
7426
+ const visible = !inputTickSet.has(tick) && isLabelVisible(tick, min, max, labelVisibility);
7427
+ return /* @__PURE__ */ jsxRuntime.jsx(
7428
+ "span",
7429
+ {
7430
+ className: "number-line-tick-label",
7431
+ style: {
7432
+ left: `${toPercent(tick, min, max)}%`,
7433
+ visibility: visible ? "visible" : "hidden"
7434
+ },
7435
+ children: tick
7436
+ },
7437
+ `lbl-${tick}`
7438
+ );
7439
+ }) });
7440
+ const HighlightLabelsRow = ({ highlights, min, max }) => {
7441
+ if (!highlights?.some((h) => h.label)) return null;
7442
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-highlight-labels-row", "aria-hidden": "true", children: highlights.map((h) => {
7443
+ if (!h.label) return null;
7444
+ const centerPct = (toPercent(h.from, min, max) + toPercent(h.to, min, max)) / 2;
7445
+ return /* @__PURE__ */ jsxRuntime.jsx(
7446
+ "span",
7447
+ {
7448
+ className: "number-line-highlight-label",
7449
+ style: { left: `${centerPct}%` },
7450
+ children: h.label
7451
+ },
7452
+ `hl-lbl-${h.id}`
7453
+ );
7454
+ }) });
7455
+ };
7456
+ const CounterControls = ({
7457
+ counter,
7458
+ count,
7459
+ onDecrement,
7460
+ onIncrement
7461
+ }) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "number-line-counter", "aria-label": counter.name ?? "Counter", children: [
7462
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "number-line-counter-controls", children: [
7463
+ /* @__PURE__ */ jsxRuntime.jsx(
7464
+ "button",
7465
+ {
7466
+ type: "button",
7467
+ className: "number-line-counter-btn",
7468
+ "aria-label": "Decrease",
7469
+ disabled: count <= counter.min,
7470
+ onClick: onDecrement,
7471
+ children: "−"
7472
+ }
7473
+ ),
7474
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "number-line-counter-value", "data-testid": "counter-value", children: count }),
7475
+ /* @__PURE__ */ jsxRuntime.jsx(
7476
+ "button",
7477
+ {
7478
+ type: "button",
7479
+ className: "number-line-counter-btn",
7480
+ "aria-label": "Increase",
7481
+ disabled: count >= counter.max,
7482
+ onClick: onIncrement,
7483
+ children: "+"
7484
+ }
7485
+ )
7486
+ ] }),
7487
+ counter.name && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "number-line-counter-label", children: counter.name })
7488
+ ] });
6845
7489
  const NumberLineRenderer = ({
6846
7490
  item,
6847
7491
  widgets,
6848
7492
  values,
6849
7493
  droppedInputs,
7494
+ validationDetails,
6850
7495
  onChange,
6851
7496
  onInputFocus
6852
7497
  }) => {
6853
- const { min, max, step, minorTicks = 0, style = "arrow-right", positions } = item;
6854
- const majorTicks = React.useMemo(() => getMajorTicks(min, max, step), [min, max, step]);
6855
- const minorValues = React.useMemo(() => getMinorTickValues(majorTicks, minorTicks), [majorTicks, minorTicks]);
6856
- const showArrowRight = style === "arrow-right" || style === "arrow-both";
6857
- const showArrowLeft = style === "arrow-left" || style === "arrow-both";
7498
+ const {
7499
+ min,
7500
+ max,
7501
+ step,
7502
+ minorTicks = 0,
7503
+ style: lineStyle = "arrow-right",
7504
+ labelVisibility,
7505
+ highlights,
7506
+ counter,
7507
+ positions
7508
+ } = item;
7509
+ const [counterCount, setCounterCount] = React.useState(
7510
+ counter !== void 0 ? counter.default ?? counter.min : 0
7511
+ );
7512
+ React.useEffect(() => {
7513
+ setCounterCount(counter !== void 0 ? counter.default ?? counter.min : 0);
7514
+ }, [counter]);
7515
+ const effectiveStep = React.useMemo(() => {
7516
+ if (counter !== void 0 && counterCount > 0) {
7517
+ return cleanFloat((max - min) / counterCount);
7518
+ }
7519
+ return step;
7520
+ }, [counter, counterCount, min, max, step]);
7521
+ const majorTicks = React.useMemo(
7522
+ () => getMajorTicks(min, max, effectiveStep).map(roundTick),
7523
+ [min, max, effectiveStep]
7524
+ );
7525
+ const minorValues = React.useMemo(
7526
+ () => getMinorTickValues(majorTicks, minorTicks),
7527
+ [majorTicks, minorTicks]
7528
+ );
7529
+ const showArrowRight = lineStyle === "arrow-right" || lineStyle === "arrow-both";
7530
+ const showArrowLeft = lineStyle === "arrow-left" || lineStyle === "arrow-both";
6858
7531
  const isDragMode = Boolean(widgets[DRAG_OPTIONS_WIDGET_ID]);
6859
7532
  const inputTickSet = React.useMemo(() => {
6860
7533
  if (isDragMode) return /* @__PURE__ */ new Set();
6861
7534
  return new Set(
6862
- positions.filter((pos) => getPositionWidgetId(pos.content) !== null).map((pos) => pos.value)
7535
+ positions.filter((pos) => getPositionWidgetId(pos.content) !== null).map((pos) => roundTick(pos.value))
6863
7536
  );
6864
7537
  }, [isDragMode, positions]);
7538
+ const visiblePositions = React.useMemo(
7539
+ () => positions.filter((pos) => !isDragMode || getPositionWidgetId(pos.content) === null),
7540
+ [isDragMode, positions]
7541
+ );
6865
7542
  return /* @__PURE__ */ jsxRuntime.jsxs(
6866
7543
  "div",
6867
7544
  {
6868
- className: `number-line${isDragMode ? " number-line--drag-mode" : ""}`,
7545
+ className: `number-line${isDragMode ? " number-line--drag-mode" : ""}${lineStyle === "line" ? " number-line--style-line" : ""}`,
6869
7546
  style: { [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%` },
6870
- role: "img",
7547
+ role: "group",
6871
7548
  "aria-label": `Number line from ${min} to ${max}`,
6872
7549
  children: [
6873
- !isDragMode && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-content-row", children: positions.map((pos) => /* @__PURE__ */ jsxRuntime.jsx(
7550
+ visiblePositions.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-content-row", children: visiblePositions.map((pos) => /* @__PURE__ */ jsxRuntime.jsx(
6874
7551
  "div",
6875
7552
  {
6876
7553
  className: "number-line-position-content",
@@ -6882,6 +7559,7 @@ const NumberLineRenderer = ({
6882
7559
  widgets,
6883
7560
  values,
6884
7561
  droppedInputs,
7562
+ validationDetails,
6885
7563
  onChange,
6886
7564
  onInputFocus
6887
7565
  },
@@ -6902,50 +7580,43 @@ const NumberLineRenderer = ({
6902
7580
  showArrowRight,
6903
7581
  positions,
6904
7582
  values,
6905
- onChange
7583
+ onChange,
7584
+ highlights,
7585
+ snapKey: counterCount
6906
7586
  }
6907
- ) : /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "number-line-axis-row", role: "presentation", children: [
6908
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-track", "aria-hidden": "true" }),
6909
- showArrowLeft && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-left", "aria-hidden": "true" }),
6910
- showArrowRight && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-right", "aria-hidden": "true" }),
6911
- minorValues.map((v) => /* @__PURE__ */ jsxRuntime.jsx(
6912
- "div",
6913
- {
6914
- className: "number-line-minor-tick",
6915
- "aria-hidden": "true",
6916
- style: { left: `${toPercent(v, min, max)}%` }
6917
- },
6918
- `minor-${v}`
6919
- )),
6920
- majorTicks.map((tick) => /* @__PURE__ */ jsxRuntime.jsx(
6921
- "div",
6922
- {
6923
- className: "number-line-major-tick",
6924
- style: { left: `${toPercent(tick, min, max)}%` }
6925
- },
6926
- `major-${tick}`
6927
- )),
6928
- positions.map((pos) => /* @__PURE__ */ jsxRuntime.jsx(
6929
- "div",
6930
- {
6931
- className: "number-line-position-dot",
6932
- style: { left: `${toPercent(pos.value, min, max)}%` }
6933
- },
6934
- `dot-${pos.id}`
6935
- ))
6936
- ] }),
6937
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-labels-row", "aria-hidden": "true", children: majorTicks.map((tick) => {
6938
- const hasInputAtTick = inputTickSet.has(tick);
6939
- return /* @__PURE__ */ jsxRuntime.jsx(
6940
- "span",
6941
- {
6942
- className: "number-line-tick-label",
6943
- style: { left: `${toPercent(tick, min, max)}%`, visibility: hasInputAtTick ? "hidden" : "visible" },
6944
- children: tick
6945
- },
6946
- `lbl-${tick}`
6947
- );
6948
- }) })
7587
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
7588
+ AxisTicks,
7589
+ {
7590
+ min,
7591
+ max,
7592
+ majorTicks,
7593
+ minorValues,
7594
+ showArrowLeft,
7595
+ showArrowRight,
7596
+ highlights,
7597
+ positions
7598
+ }
7599
+ ),
7600
+ /* @__PURE__ */ jsxRuntime.jsx(
7601
+ AxisLabels,
7602
+ {
7603
+ majorTicks,
7604
+ min,
7605
+ max,
7606
+ labelVisibility,
7607
+ inputTickSet
7608
+ }
7609
+ ),
7610
+ /* @__PURE__ */ jsxRuntime.jsx(HighlightLabelsRow, { highlights, min, max }),
7611
+ counter !== void 0 && /* @__PURE__ */ jsxRuntime.jsx(
7612
+ CounterControls,
7613
+ {
7614
+ counter,
7615
+ count: counterCount,
7616
+ onDecrement: () => setCounterCount((c) => Math.max(counter.min, c - 1)),
7617
+ onIncrement: () => setCounterCount((c) => Math.min(counter.max, c + 1))
7618
+ }
7619
+ )
6949
7620
  ]
6950
7621
  }
6951
7622
  );
@@ -7013,6 +7684,7 @@ const LayoutRenderer = ({
7013
7684
  widgets,
7014
7685
  values,
7015
7686
  droppedInputs,
7687
+ validationDetails,
7016
7688
  onChange,
7017
7689
  onInputFocus
7018
7690
  }) => {
@@ -7065,13 +7737,18 @@ const LayoutRenderer = ({
7065
7737
  case JSON_TYPES.WIDGET_REF: {
7066
7738
  const widget = widgets[item.widgetId];
7067
7739
  if (!widget) return null;
7740
+ const isCorrect = validationDetails?.[item.widgetId];
7741
+ const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
7068
7742
  return /* @__PURE__ */ jsxRuntime.jsx(
7069
7743
  WidgetRenderer,
7070
7744
  {
7071
7745
  widget,
7072
7746
  widgetId: item.widgetId,
7073
7747
  value: values[item.widgetId] || "",
7748
+ values,
7074
7749
  isDropped: droppedInputs.has(item.widgetId),
7750
+ validationStatus,
7751
+ droppedInputs,
7075
7752
  onChange,
7076
7753
  onInputFocus
7077
7754
  }
@@ -7091,6 +7768,7 @@ const LayoutRenderer = ({
7091
7768
  widgets,
7092
7769
  values,
7093
7770
  droppedInputs,
7771
+ validationDetails,
7094
7772
  onChange,
7095
7773
  onInputFocus
7096
7774
  },
@@ -7109,6 +7787,7 @@ const LayoutRenderer = ({
7109
7787
  widgets,
7110
7788
  values,
7111
7789
  droppedInputs,
7790
+ validationDetails,
7112
7791
  onChange,
7113
7792
  onInputFocus
7114
7793
  },
@@ -7125,6 +7804,7 @@ const LayoutRenderer = ({
7125
7804
  widgets,
7126
7805
  values,
7127
7806
  droppedInputs,
7807
+ validationDetails,
7128
7808
  onChange,
7129
7809
  onInputFocus
7130
7810
  }
@@ -7152,9 +7832,15 @@ const ExerciseRenderer = React.forwardRef(({
7152
7832
  const [values, setValues] = React.useState({});
7153
7833
  const [droppedSources, setDroppedSources] = React.useState(/* @__PURE__ */ new Map());
7154
7834
  const [focusedInputId, setFocusedInputId] = React.useState(null);
7835
+ const [validationDetails, setValidationDetails] = React.useState(null);
7155
7836
  React.useImperativeHandle(ref, () => ({
7156
7837
  submit: () => {
7157
7838
  const result = validate(json, values);
7839
+ const details = {};
7840
+ for (const [id, v] of Object.entries(result.details)) {
7841
+ details[id] = v.isCorrect;
7842
+ }
7843
+ setValidationDetails(details);
7158
7844
  onSubmit?.(values);
7159
7845
  return result;
7160
7846
  },
@@ -7163,6 +7849,7 @@ const ExerciseRenderer = React.forwardRef(({
7163
7849
  setValues({});
7164
7850
  setDroppedSources(/* @__PURE__ */ new Map());
7165
7851
  setFocusedInputId(null);
7852
+ setValidationDetails(null);
7166
7853
  },
7167
7854
  focusFirstInput: () => {
7168
7855
  const container = document.querySelector(".math-exercise-renderer");
@@ -7173,8 +7860,7 @@ const ExerciseRenderer = React.forwardRef(({
7173
7860
  }
7174
7861
  }), [json, values, onSubmit]);
7175
7862
  const handleChange = (widgetId, value, fromDrop = false, sourceOptionId) => {
7176
- const newValues = { ...values, [widgetId]: value };
7177
- setValues(newValues);
7863
+ setValues((prev) => ({ ...prev, [widgetId]: value }));
7178
7864
  setDroppedSources((prev) => {
7179
7865
  const next = new Map(prev);
7180
7866
  if (fromDrop && value !== "" && sourceOptionId) {
@@ -7184,7 +7870,7 @@ const ExerciseRenderer = React.forwardRef(({
7184
7870
  }
7185
7871
  return next;
7186
7872
  });
7187
- onChange?.(newValues);
7873
+ onChange?.({ ...values, [widgetId]: value });
7188
7874
  };
7189
7875
  const handleSubmit = () => {
7190
7876
  onSubmit?.(values);
@@ -7277,6 +7963,7 @@ const ExerciseRenderer = React.forwardRef(({
7277
7963
  widgets: json.widgets,
7278
7964
  values,
7279
7965
  droppedInputs,
7966
+ validationDetails,
7280
7967
  onChange: handleChange,
7281
7968
  onInputFocus: setFocusedInputId
7282
7969
  },
@@ -7291,6 +7978,7 @@ const ExerciseRenderer = React.forwardRef(({
7291
7978
  widgets: json.widgets,
7292
7979
  values,
7293
7980
  droppedInputs,
7981
+ validationDetails,
7294
7982
  onChange: handleChange,
7295
7983
  onInputFocus: setFocusedInputId
7296
7984
  },
@@ -7306,6 +7994,7 @@ const ExerciseRenderer = React.forwardRef(({
7306
7994
  widgets: json.widgets,
7307
7995
  values,
7308
7996
  droppedInputs,
7997
+ validationDetails,
7309
7998
  onChange: handleChange,
7310
7999
  onInputFocus: setFocusedInputId
7311
8000
  },
@@ -7394,6 +8083,8 @@ exports.ExerciseJSONSchema = ExerciseJSONSchema;
7394
8083
  exports.ExerciseMetaSchema = ExerciseMetaSchema;
7395
8084
  exports.ExerciseRenderer = ExerciseRenderer;
7396
8085
  exports.ExpressionValidationSchema = ExpressionValidationSchema;
8086
+ exports.FractionInlineSchema = FractionInlineSchema;
8087
+ exports.FractionJSONSchema = FractionJSONSchema;
7397
8088
  exports.FreeTextInlineSchema = FreeTextInlineSchema;
7398
8089
  exports.GroupItemSchema = GroupItemSchema;
7399
8090
  exports.HighlightedTextInlineSchema = HighlightedTextInlineSchema;
@@ -7407,6 +8098,10 @@ exports.LayoutRenderer = LayoutRenderer;
7407
8098
  exports.LexerError = LexerError;
7408
8099
  exports.LineBlockSchema = LineBlockSchema;
7409
8100
  exports.LinebreakSchema = LinebreakSchema;
8101
+ exports.NumberLineCounterItemSchema = NumberLineCounterItemSchema;
8102
+ exports.NumberLineHighlightItemSchema = NumberLineHighlightItemSchema;
8103
+ exports.NumberLineItemSchema = NumberLineItemSchema;
8104
+ exports.NumberLinePositionItemSchema = NumberLinePositionItemSchema;
7410
8105
  exports.NumericKeypadJSONSchema = NumericKeypadJSONSchema;
7411
8106
  exports.NumericKeypadToolSchema = NumericKeypadToolSchema;
7412
8107
  exports.OperatorItemSchema = OperatorItemSchema;
@@ -7437,6 +8132,7 @@ exports.createColorText = createColorText;
7437
8132
  exports.createDragOptions = createDragOptions;
7438
8133
  exports.createExerciseDocument = createExerciseDocument;
7439
8134
  exports.createExpressionValidation = createExpressionValidation;
8135
+ exports.createFraction = createFraction;
7440
8136
  exports.createFreeText = createFreeText;
7441
8137
  exports.createHighlightedText = createHighlightedText;
7442
8138
  exports.createLineBlock = createLineBlock;
@@ -7449,6 +8145,7 @@ exports.createTextBlock = createTextBlock;
7449
8145
  exports.evaluate = evaluate;
7450
8146
  exports.extractExpressionVariables = extractVariables;
7451
8147
  exports.findValidCombination = findValidCombination;
8148
+ exports.getFractionKey = getFractionKey;
7452
8149
  exports.getMissingInputs = getMissingInputs;
7453
8150
  exports.isBoldText = isBoldText;
7454
8151
  exports.isColorText = isColorText;
@@ -7456,6 +8153,9 @@ exports.isDragOptions = isDragOptions;
7456
8153
  exports.isDragOptionsJSON = isDragOptionsJSON;
7457
8154
  exports.isExerciseDocument = isExerciseDocument;
7458
8155
  exports.isExpressionValidation = isExpressionValidation;
8156
+ exports.isFraction = isFraction;
8157
+ exports.isFractionJSON = isFractionJSON;
8158
+ exports.isFractionKey = isFractionKey;
7459
8159
  exports.isFreeText = isFreeText;
7460
8160
  exports.isGroupItem = isGroupItem;
7461
8161
  exports.isHighlightedText = isHighlightedText;
@@ -7480,8 +8180,9 @@ exports.isTextItem = isTextItem$1;
7480
8180
  exports.isToolNode = isToolNode;
7481
8181
  exports.isValidExpression = isValidExpression;
7482
8182
  exports.isValidationNode = isValidationNode;
7483
- exports.isWidgetRef = isWidgetRef$1;
8183
+ exports.isWidgetRef = isWidgetRef;
7484
8184
  exports.parse = parse;
8185
+ exports.parseFractionKey = parseFractionKey;
7485
8186
  exports.parseSafe = parseSafe;
7486
8187
  exports.renderExercise = renderExercise;
7487
8188
  exports.toAST = toAST;