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