@mathscan/math-exercise-engine 1.0.15 → 1.0.17

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
@@ -54,6 +54,8 @@ const AST_TYPES = {
54
54
  MATCHER_ROW: "MatcherRow",
55
55
  /** Item within a matcher row */
56
56
  MATCHER_ITEM: "MatcherItem",
57
+ /** Visual fraction inline widget */
58
+ VISUAL_FRACTION: "VisualFraction",
57
59
  // ═══════════════════════════════════════════════════════════════════════════
58
60
  // Tools
59
61
  // ═══════════════════════════════════════════════════════════════════════════
@@ -134,7 +136,9 @@ const JSON_TYPES = {
134
136
  /** Numeric keypad widget */
135
137
  NUMERIC_KEYPAD: "numeric-keypad",
136
138
  /** Inline fraction widget */
137
- FRACTION: "fraction"
139
+ FRACTION: "fraction",
140
+ /** Visual fraction widget */
141
+ VISUAL_FRACTION: "visual-fraction"
138
142
  };
139
143
  const DRAG_OPTIONS_WIDGET_ID = "_drag_options";
140
144
  const VALIDATION_MODES = {
@@ -156,6 +160,7 @@ const SIDE_PAD = 7;
156
160
  const CSS_VAR_SIDE_PAD = "--nl-side-pad";
157
161
  const NUMBER_LINE_LABELS = ["all", "extremes", "min-only", "max-only", "none"];
158
162
  const NUMBER_LINE_HIGHLIGHT_COLORS = ["blue", "red", "green", "orange", "purple", "yellow"];
163
+ const NUMBER_LINE_DRAG_MODES = ["axis", "inputs"];
159
164
  function getFractionKey(widgetId, part) {
160
165
  return `${widgetId}__${part}`;
161
166
  }
@@ -268,6 +273,10 @@ const HIGHLIGHT = chevrotain.createToken({
268
273
  name: "HIGHLIGHT",
269
274
  pattern: /HIGHLIGHT(?!_TEXT)/
270
275
  });
276
+ const VISUAL_FRACTION = chevrotain.createToken({
277
+ name: "VISUAL_FRACTION",
278
+ pattern: /VISUAL_FRACTION/
279
+ });
271
280
  const FRACTION = chevrotain.createToken({
272
281
  name: "FRACTION",
273
282
  pattern: /FRACTION/
@@ -397,6 +406,7 @@ const allTokens = [
397
406
  CELL,
398
407
  COUNTER,
399
408
  HIGHLIGHT,
409
+ VISUAL_FRACTION,
400
410
  FRACTION,
401
411
  MATCHER,
402
412
  ITEM,
@@ -862,6 +872,8 @@ class MathExerciseParser extends chevrotain.CstParser {
862
872
  { ALT: () => this.SUBRULE(this.fraction) },
863
873
  // IMAGE (inline image element)
864
874
  { ALT: () => this.SUBRULE(this.image) },
875
+ // VISUAL_FRACTION — always allowed; display-only when answer=[] or absence of answer parameter, interactive when answer=[...] with values.
876
+ { ALT: () => this.SUBRULE(this.visualFraction) },
865
877
  // W_S_I (inline input widget) — excluded in display-only contexts (e.g. MATCHER ITEM)
866
878
  { GATE: () => !displayOnly, ALT: () => this.SUBRULE(this.inlineInput) }
867
879
  ]);
@@ -899,6 +911,41 @@ class MathExerciseParser extends chevrotain.CstParser {
899
911
  this.SUBRULE(this.parameterList);
900
912
  this.CONSUME(RBracket);
901
913
  });
914
+ this.visualFraction = this.RULE("visualFraction", () => {
915
+ this.CONSUME(VISUAL_FRACTION);
916
+ this.CONSUME(LBracket);
917
+ this.SUBRULE(this.parameterList);
918
+ this.CONSUME(RBracket);
919
+ });
920
+ this.numberArray = this.RULE("numberArray", () => {
921
+ this.CONSUME(LBracket);
922
+ this.MANY(() => {
923
+ this.CONSUME(Space);
924
+ });
925
+ this.OPTION(() => {
926
+ this.CONSUME(NumberLiteral, { LABEL: "items" });
927
+ this.MANY2(() => {
928
+ this.MANY3(() => {
929
+ this.CONSUME2(Space);
930
+ });
931
+ this.CONSUME(Comma);
932
+ this.MANY4(() => {
933
+ this.CONSUME3(Space);
934
+ });
935
+ this.CONSUME2(NumberLiteral, { LABEL: "items" });
936
+ });
937
+ this.OPTION2(() => {
938
+ this.MANY5(() => {
939
+ this.CONSUME4(Space);
940
+ });
941
+ this.CONSUME2(Comma);
942
+ });
943
+ });
944
+ this.MANY6(() => {
945
+ this.CONSUME5(Space);
946
+ });
947
+ this.CONSUME(RBracket);
948
+ });
902
949
  this.inlineInput = this.RULE("inlineInput", () => {
903
950
  this.OR([
904
951
  { ALT: () => this.CONSUME(W_S_I, { LABEL: "stringInput" }) },
@@ -986,7 +1033,9 @@ class MathExerciseParser extends chevrotain.CstParser {
986
1033
  // Tuple value: (start, end)
987
1034
  { ALT: () => this.SUBRULE(this.tuple, { LABEL: "value" }) },
988
1035
  // Curly-brace string list: {"a", "b", "c"}
989
- { ALT: () => this.SUBRULE(this.curlyStringList, { LABEL: "value" }) }
1036
+ { ALT: () => this.SUBRULE(this.curlyStringList, { LABEL: "value" }) },
1037
+ // Square-bracket number array: [1, 2, 3] — used by VISUAL_FRACTION filled/answer
1038
+ { ALT: () => this.SUBRULE(this.numberArray, { LABEL: "value" }) }
990
1039
  ]);
991
1040
  });
992
1041
  this.tuple = this.RULE("tuple", () => {
@@ -1042,6 +1091,25 @@ class MathExerciseParser extends chevrotain.CstParser {
1042
1091
  }
1043
1092
  }
1044
1093
  const parser = new MathExerciseParser();
1094
+ function parseOrThrow(schema, data, label) {
1095
+ const result = schema.safeParse(data);
1096
+ if (!result.success) {
1097
+ const issue = result.error.issues[0];
1098
+ if (!issue) throw new Error(`${label}: validation failed`);
1099
+ const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
1100
+ throw new Error(`${label}: ${path} ${issue.message}`);
1101
+ }
1102
+ return result.data;
1103
+ }
1104
+ const VISUAL_FRACTION_SHAPES = ["bar", "circle", "triangle", "grid"];
1105
+ const VISUAL_FRACTION_SHAPE = {
1106
+ BAR: "bar",
1107
+ CIRCLE: "circle",
1108
+ TRIANGLE: "triangle",
1109
+ GRID: "grid"
1110
+ };
1111
+ const VISUAL_FRACTION_DEFAULT_SIZE = 1.35;
1112
+ const VISUAL_FRACTION_MAX_DIVISIONS = 100;
1045
1113
  const FreeTextInlineSchema = zod.z.object({
1046
1114
  type: zod.z.literal(AST_TYPES.FREE_TEXT),
1047
1115
  value: zod.z.string()
@@ -1071,6 +1139,7 @@ const InlineInputWidgetSchema = zod.z.discriminatedUnion("inputType", [
1071
1139
  name: zod.z.string(),
1072
1140
  inputType: zod.z.literal(INPUT_TYPES.STRING),
1073
1141
  length: zod.z.number().int().positive(),
1142
+ placeholder: zod.z.string().max(32).optional(),
1074
1143
  answer: zod.z.union([zod.z.string(), zod.z.number()]).optional()
1075
1144
  }),
1076
1145
  zod.z.object({
@@ -1078,6 +1147,7 @@ const InlineInputWidgetSchema = zod.z.discriminatedUnion("inputType", [
1078
1147
  name: zod.z.string(),
1079
1148
  inputType: zod.z.literal(INPUT_TYPES.NUMERIC),
1080
1149
  digits: zod.z.number().int().positive(),
1150
+ placeholder: zod.z.string().max(32).optional(),
1081
1151
  answer: zod.z.union([zod.z.string(), zod.z.number()]).optional()
1082
1152
  })
1083
1153
  ]);
@@ -1087,7 +1157,8 @@ const FractionInlineSchema = zod.z.object({
1087
1157
  numerator: zod.z.number().optional(),
1088
1158
  denominator: zod.z.number().optional(),
1089
1159
  answer: zod.z.string().optional(),
1090
- simplified: zod.z.boolean().default(true)
1160
+ simplified: zod.z.boolean().default(true),
1161
+ placeholder: zod.z.string().max(32).optional()
1091
1162
  });
1092
1163
  const IMAGE_SRC_SCHEME_RE$1 = /^(https?:\/\/|data:image\/)/i;
1093
1164
  const ImageInlineSchema = zod.z.object({
@@ -1100,6 +1171,60 @@ const ImageInlineSchema = zod.z.object({
1100
1171
  width: zod.z.number().positive().optional(),
1101
1172
  height: zod.z.number().positive().optional()
1102
1173
  });
1174
+ const VisualFractionInlineSchema = zod.z.object({
1175
+ type: zod.z.literal(AST_TYPES.VISUAL_FRACTION),
1176
+ name: zod.z.string(),
1177
+ shape: zod.z.enum(VISUAL_FRACTION_SHAPES).default("bar"),
1178
+ divisions: zod.z.number().int().positive().max(VISUAL_FRACTION_MAX_DIVISIONS, {
1179
+ message: `divisions cannot exceed ${VISUAL_FRACTION_MAX_DIVISIONS}`
1180
+ }),
1181
+ filled: zod.z.array(zod.z.number().int().nonnegative()).default([]),
1182
+ answer: zod.z.array(zod.z.number().int().nonnegative()).default([]),
1183
+ orientation: zod.z.enum(["horizontal", "vertical"]).optional(),
1184
+ label: zod.z.string().optional(),
1185
+ /** Unified author size.
1186
+ * - bar / grid: overrides `--visual-fraction-part-size` (cell size).
1187
+ * - circle / triangle: maps to `--visual-fraction-size` with the renderer scale factor.
1188
+ * Omit to use CSS defaults. */
1189
+ size: zod.z.number().positive().optional()
1190
+ }).superRefine(validateVisualFractionCapacity);
1191
+ function validateVisualFractionCapacity(data, ctx) {
1192
+ const filledTotal = data.filled.reduce((sum, count) => sum + count, 0);
1193
+ const answerTotal = data.answer.reduce((sum, count) => sum + count, 0);
1194
+ if (filledTotal > data.divisions) {
1195
+ ctx.addIssue({
1196
+ code: zod.z.ZodIssueCode.custom,
1197
+ message: `filled total (${filledTotal}) cannot exceed divisions (${data.divisions})`,
1198
+ path: ["filled"]
1199
+ });
1200
+ }
1201
+ if (filledTotal + answerTotal > data.divisions) {
1202
+ ctx.addIssue({
1203
+ code: zod.z.ZodIssueCode.custom,
1204
+ message: `filled + answer total (${filledTotal + answerTotal}) cannot exceed divisions (${data.divisions})`,
1205
+ path: ["answer"]
1206
+ });
1207
+ }
1208
+ const shape = data.shape;
1209
+ if (shape === "triangle" && !isPerfectSquare(data.divisions)) {
1210
+ ctx.addIssue({
1211
+ code: zod.z.ZodIssueCode.custom,
1212
+ message: `triangle divisions (${data.divisions}) must be a perfect square to render equal triangle parts`,
1213
+ path: ["divisions"]
1214
+ });
1215
+ }
1216
+ if (shape === "grid" && !isPerfectSquare(data.divisions)) {
1217
+ ctx.addIssue({
1218
+ code: zod.z.ZodIssueCode.custom,
1219
+ message: `grid divisions (${data.divisions}) must be a perfect square to render equal rows and columns`,
1220
+ path: ["divisions"]
1221
+ });
1222
+ }
1223
+ }
1224
+ function isPerfectSquare(value) {
1225
+ const root = Math.sqrt(value);
1226
+ return Number.isInteger(root);
1227
+ }
1103
1228
  const InlineNodeSchema = zod.z.union([
1104
1229
  FreeTextInlineSchema,
1105
1230
  SpaceInlineSchema,
@@ -1108,7 +1233,8 @@ const InlineNodeSchema = zod.z.union([
1108
1233
  BoldTextInlineSchema,
1109
1234
  InlineInputWidgetSchema,
1110
1235
  FractionInlineSchema,
1111
- ImageInlineSchema
1236
+ ImageInlineSchema,
1237
+ VisualFractionInlineSchema
1112
1238
  ]);
1113
1239
  function createFreeText(value) {
1114
1240
  return FreeTextInlineSchema.parse({
@@ -1143,23 +1269,25 @@ function createBoldText(value, highlight) {
1143
1269
  highlight
1144
1270
  });
1145
1271
  }
1146
- function createStringInput(name, length, answer) {
1147
- return InlineInputWidgetSchema.parse({
1272
+ function createStringInput(name, length, answer, placeholder) {
1273
+ return parseOrThrow(InlineInputWidgetSchema, {
1148
1274
  type: AST_TYPES.INLINE_INPUT,
1149
1275
  name,
1150
1276
  inputType: INPUT_TYPES.STRING,
1151
1277
  length,
1278
+ placeholder,
1152
1279
  answer
1153
- });
1280
+ }, "STRING_INPUT");
1154
1281
  }
1155
- function createNumericInput(name, digits, answer) {
1156
- return InlineInputWidgetSchema.parse({
1282
+ function createNumericInput(name, digits, answer, placeholder) {
1283
+ return parseOrThrow(InlineInputWidgetSchema, {
1157
1284
  type: AST_TYPES.INLINE_INPUT,
1158
1285
  name,
1159
1286
  inputType: INPUT_TYPES.NUMERIC,
1160
1287
  digits,
1288
+ placeholder,
1161
1289
  answer
1162
- });
1290
+ }, "NUMERIC_INPUT");
1163
1291
  }
1164
1292
  function createImage(name, src, alt, options = {}) {
1165
1293
  return ImageInlineSchema.parse({
@@ -1177,6 +1305,14 @@ function createFraction(name, options = {}) {
1177
1305
  ...options
1178
1306
  });
1179
1307
  }
1308
+ function createVisualFraction(name, divisions, options = {}) {
1309
+ return VisualFractionInlineSchema.parse({
1310
+ type: AST_TYPES.VISUAL_FRACTION,
1311
+ name,
1312
+ divisions,
1313
+ ...options
1314
+ });
1315
+ }
1180
1316
  function isFreeText(value) {
1181
1317
  return FreeTextInlineSchema.safeParse(value).success;
1182
1318
  }
@@ -1195,6 +1331,9 @@ function isImage(value) {
1195
1331
  function isInlineNode(value) {
1196
1332
  return InlineNodeSchema.safeParse(value).success;
1197
1333
  }
1334
+ function isVisualFractionInline(value) {
1335
+ return VisualFractionInlineSchema.safeParse(value).success;
1336
+ }
1198
1337
  const TextBlockSchema = zod.z.object({
1199
1338
  type: zod.z.literal(AST_TYPES.TEXT_BLOCK),
1200
1339
  value: zod.z.string()
@@ -1301,6 +1440,11 @@ const NumberLineBlockSchema = zod.z.object({
1301
1440
  }).optional(),
1302
1441
  counter: NumberLineCounterSchema.optional(),
1303
1442
  highlights: zod.z.array(NumberLineHighlightSchema).optional(),
1443
+ dragMode: zod.z.enum(NUMBER_LINE_DRAG_MODES, {
1444
+ errorMap: () => ({
1445
+ message: `NUMBER_LINE: dragMode must be one of ${NUMBER_LINE_DRAG_MODES.join(", ")}`
1446
+ })
1447
+ }).optional(),
1304
1448
  positions: zod.z.array(NumberLinePositionSchema)
1305
1449
  }).refine((data) => data.min < data.max, {
1306
1450
  message: "NUMBER_LINE: min must be less than max"
@@ -1338,7 +1482,7 @@ const NumberLineBlockSchema = zod.z.object({
1338
1482
  }
1339
1483
  }
1340
1484
  });
1341
- function createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights) {
1485
+ function createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights, dragMode) {
1342
1486
  return NumberLineBlockSchema.parse({
1343
1487
  type: AST_TYPES.NUMBER_LINE_BLOCK,
1344
1488
  min,
@@ -1349,6 +1493,7 @@ function createNumberLineBlock(min, max, positions, step, minorTicks, style, lab
1349
1493
  labelVisibility,
1350
1494
  counter,
1351
1495
  highlights,
1496
+ dragMode,
1352
1497
  positions
1353
1498
  });
1354
1499
  }
@@ -1390,6 +1535,10 @@ const DisplayFractionInlineSchema = FractionInlineSchema.refine(
1390
1535
  (f) => f.numerator !== void 0 && f.denominator !== void 0,
1391
1536
  { message: "FRACTION inside MATCHER ITEM must be in display mode (both numerator and denominator required)" }
1392
1537
  );
1538
+ const DisplayVisualFractionInlineSchema = VisualFractionInlineSchema.refine(
1539
+ (vf) => vf.answer.length === 0,
1540
+ { message: "VISUAL_FRACTION inside MATCHER ITEM must be display-only (answer must be empty: answer=[])" }
1541
+ );
1393
1542
  const DisplayInlineNodeSchema = zod.z.union([
1394
1543
  FreeTextInlineSchema,
1395
1544
  SpaceInlineSchema,
@@ -1397,7 +1546,8 @@ const DisplayInlineNodeSchema = zod.z.union([
1397
1546
  ColorTextInlineSchema,
1398
1547
  BoldTextInlineSchema,
1399
1548
  DisplayFractionInlineSchema,
1400
- ImageInlineSchema
1549
+ ImageInlineSchema,
1550
+ DisplayVisualFractionInlineSchema
1401
1551
  ]);
1402
1552
  const MatcherItemSchema = zod.z.object({
1403
1553
  type: zod.z.literal(AST_TYPES.MATCHER_ITEM),
@@ -1493,7 +1643,8 @@ const DragOptionsToolSchema = zod.z.object({
1493
1643
  const NumericKeypadToolSchema = zod.z.object({
1494
1644
  type: zod.z.literal(AST_TYPES.NUMERIC_KEYPAD),
1495
1645
  includeOperators: zod.z.boolean().optional(),
1496
- includeDecimal: zod.z.boolean().optional()
1646
+ includeDecimal: zod.z.boolean().optional(),
1647
+ style: zod.z.enum(["diamond", "classic"]).optional()
1497
1648
  });
1498
1649
  const ToolNodeSchema = zod.z.discriminatedUnion("type", [
1499
1650
  DragOptionsToolSchema,
@@ -1505,11 +1656,12 @@ function createDragOptions(options) {
1505
1656
  options
1506
1657
  });
1507
1658
  }
1508
- function createNumericKeypad(includeOperators, includeDecimal) {
1659
+ function createNumericKeypad(includeOperators, includeDecimal, style) {
1509
1660
  return NumericKeypadToolSchema.parse({
1510
1661
  type: AST_TYPES.NUMERIC_KEYPAD,
1511
1662
  includeOperators,
1512
- includeDecimal
1663
+ includeDecimal,
1664
+ style
1513
1665
  });
1514
1666
  }
1515
1667
  function isDragOptions(value) {
@@ -1706,6 +1858,7 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1706
1858
  const minorTicks = params.minorTicks !== void 0 ? Number(params.minorTicks) : void 0;
1707
1859
  const style = params.style;
1708
1860
  const labelVisibility = params.labelVisibility;
1861
+ const dragMode = params.dragMode;
1709
1862
  const positions = ctx.position ? ctx.position.map((p) => this.visit(p)) : [];
1710
1863
  const rawCounters = ctx.counter;
1711
1864
  if (rawCounters && rawCounters.length > 1) {
@@ -1713,7 +1866,7 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1713
1866
  }
1714
1867
  const counter = rawCounters ? this.visit(rawCounters) : void 0;
1715
1868
  const highlights = ctx.highlight ? ctx.highlight.map((h) => this.visit(h)) : [];
1716
- return createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights);
1869
+ return createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights, dragMode);
1717
1870
  }
1718
1871
  position(ctx) {
1719
1872
  const params = this.visit(ctx.parameterList);
@@ -1863,6 +2016,9 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1863
2016
  if (ctx.image) {
1864
2017
  return this.visit(ctx.image);
1865
2018
  }
2019
+ if (ctx.visualFraction) {
2020
+ return this.visit(ctx.visualFraction);
2021
+ }
1866
2022
  if (ctx.inlineInput) {
1867
2023
  return this.visit(ctx.inlineInput);
1868
2024
  }
@@ -1903,7 +2059,8 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1903
2059
  const denominator = params.denominator;
1904
2060
  const answer = params.answer;
1905
2061
  const simplified = params.simplified !== void 0 ? params.simplified : void 0;
1906
- return createFraction(name, { numerator, denominator, answer, simplified });
2062
+ const placeholder = params.placeholder;
2063
+ return createFraction(name, { numerator, denominator, answer, simplified, placeholder });
1907
2064
  }
1908
2065
  image(ctx) {
1909
2066
  const params = this.visit(ctx.parameterList);
@@ -1914,16 +2071,32 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1914
2071
  const height = params.height;
1915
2072
  return createImage(name, src, alt, { width, height });
1916
2073
  }
2074
+ visualFraction(ctx) {
2075
+ const params = this.visit(ctx.parameterList);
2076
+ const name = params.name;
2077
+ const divisions = params.divisions;
2078
+ const shape = params.shape;
2079
+ const filled = params.filled ?? [];
2080
+ const answer = params.answer ?? [];
2081
+ const orientation = params.orientation;
2082
+ const label = params.label !== void 0 ? params.label : void 0;
2083
+ const size = params.size !== void 0 ? params.size : void 0;
2084
+ return createVisualFraction(name, divisions, { shape, filled, answer, orientation, label, size });
2085
+ }
2086
+ numberArray(ctx) {
2087
+ return ctx.items ? ctx.items.map((t) => parseFloat(t.image)) : [];
2088
+ }
1917
2089
  inlineInput(ctx) {
1918
2090
  const params = this.visit(ctx.parameterList);
1919
2091
  const name = params.name;
1920
2092
  const answer = params.answer;
2093
+ const placeholder = params.placeholder;
1921
2094
  if (ctx.stringInput) {
1922
2095
  const length = params.length;
1923
- return createStringInput(name, length, answer);
2096
+ return createStringInput(name, length, answer, placeholder);
1924
2097
  }
1925
2098
  const digits = params.digits;
1926
- return createNumericInput(name, digits, answer);
2099
+ return createNumericInput(name, digits, answer, placeholder);
1927
2100
  }
1928
2101
  // ═══════════════════════════════════════════════════════════════════════════
1929
2102
  // Tools
@@ -1945,7 +2118,8 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1945
2118
  const params = ctx.parameterList ? this.visit(ctx.parameterList) : {};
1946
2119
  const includeOperators = params.includeOperators === true || params.includeOperators === "true";
1947
2120
  const includeDecimal = params.includeDecimal === true || params.includeDecimal === "true";
1948
- return createNumericKeypad(includeOperators || void 0, includeDecimal || void 0);
2121
+ const style = params.style;
2122
+ return createNumericKeypad(includeOperators || void 0, includeDecimal || void 0, style);
1949
2123
  }
1950
2124
  // ═══════════════════════════════════════════════════════════════════════════
1951
2125
  // Validation
@@ -2170,6 +2344,7 @@ const NumberLineItemSchema = zod.z.object({
2170
2344
  labelVisibility: zod.z.enum(NUMBER_LINE_LABELS).optional(),
2171
2345
  counter: NumberLineCounterItemSchema.optional(),
2172
2346
  highlights: zod.z.array(NumberLineHighlightItemSchema).optional(),
2347
+ dragMode: zod.z.enum(NUMBER_LINE_DRAG_MODES).optional(),
2173
2348
  positions: zod.z.array(NumberLinePositionItemSchema)
2174
2349
  });
2175
2350
  const MatcherItemContentSchema = zod.z.object({
@@ -2188,12 +2363,14 @@ const MatcherRowItemSchema = zod.z.object({
2188
2363
  const StringInputJSONSchema = zod.z.object({
2189
2364
  type: zod.z.literal(JSON_TYPES.INLINE_INPUT),
2190
2365
  inputType: zod.z.literal(INPUT_TYPES.STRING),
2191
- length: zod.z.number().int().positive()
2366
+ length: zod.z.number().int().positive(),
2367
+ placeholder: zod.z.string().max(32).optional()
2192
2368
  });
2193
2369
  const NumericInputJSONSchema = zod.z.object({
2194
2370
  type: zod.z.literal(JSON_TYPES.INLINE_INPUT),
2195
2371
  inputType: zod.z.literal(INPUT_TYPES.NUMERIC),
2196
- digits: zod.z.number().int().positive()
2372
+ digits: zod.z.number().int().positive(),
2373
+ placeholder: zod.z.string().max(32).optional()
2197
2374
  });
2198
2375
  const InlineInputJSONSchema = zod.z.discriminatedUnion("inputType", [
2199
2376
  StringInputJSONSchema,
@@ -2210,13 +2387,15 @@ const RadioOptionsJSONSchema = zod.z.object({
2210
2387
  const NumericKeypadJSONSchema = zod.z.object({
2211
2388
  type: zod.z.literal(JSON_TYPES.NUMERIC_KEYPAD),
2212
2389
  includeOperators: zod.z.boolean().optional(),
2213
- includeDecimal: zod.z.boolean().optional()
2390
+ includeDecimal: zod.z.boolean().optional(),
2391
+ style: zod.z.enum(["diamond", "classic"]).optional()
2214
2392
  });
2215
2393
  const FractionJSONSchema = zod.z.object({
2216
2394
  type: zod.z.literal(JSON_TYPES.FRACTION),
2217
2395
  numerator: zod.z.number().nullable(),
2218
2396
  denominator: zod.z.number().nullable(),
2219
- simplified: zod.z.boolean()
2397
+ simplified: zod.z.boolean(),
2398
+ placeholder: zod.z.string().max(32).optional()
2220
2399
  });
2221
2400
  const MatcherWidgetJSONSchema = zod.z.object({
2222
2401
  type: zod.z.literal(JSON_TYPES.MATCHER),
@@ -2226,24 +2405,44 @@ const MatcherWidgetJSONSchema = zod.z.object({
2226
2405
  /** Number of answer groups — equals answers.length, stored here to avoid prop-drilling. */
2227
2406
  groupCount: zod.z.number().int().min(1)
2228
2407
  });
2408
+ const VisualFractionJSONSchema = zod.z.object({
2409
+ type: zod.z.literal(JSON_TYPES.VISUAL_FRACTION),
2410
+ shape: zod.z.enum(["bar", "circle", "triangle", "grid"]).default("bar"),
2411
+ divisions: zod.z.number().int().positive().max(VISUAL_FRACTION_MAX_DIVISIONS, {
2412
+ message: `divisions cannot exceed ${VISUAL_FRACTION_MAX_DIVISIONS}`
2413
+ }),
2414
+ filled: zod.z.array(zod.z.number().int().nonnegative()),
2415
+ answer: zod.z.array(zod.z.number().int().nonnegative()),
2416
+ orientation: zod.z.enum(["horizontal", "vertical"]).optional(),
2417
+ /** Optional label text rendered near the shape — replaces the old showLabel boolean */
2418
+ label: zod.z.string().optional(),
2419
+ /** Overall shape size in unified author units.
2420
+ * - bar / grid: overrides `--visual-fraction-part-size` CSS custom property (cell size in rem).
2421
+ * - circle / triangle: overrides `--visual-fraction-size` through the renderer scale factor.
2422
+ */
2423
+ size: zod.z.number().positive().optional()
2424
+ }).superRefine(validateVisualFractionCapacity);
2229
2425
  const WidgetJSONSchema = zod.z.union([
2230
2426
  InlineInputJSONSchema,
2231
2427
  DragOptionsJSONSchema,
2232
2428
  RadioOptionsJSONSchema,
2233
2429
  NumericKeypadJSONSchema,
2234
2430
  FractionJSONSchema,
2235
- MatcherWidgetJSONSchema
2431
+ MatcherWidgetJSONSchema,
2432
+ VisualFractionJSONSchema
2236
2433
  ]);
2237
2434
  const ValidationJSONSchema = zod.z.object({
2238
2435
  mode: zod.z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION]),
2239
2436
  // Simple answers: string|number per widget id.
2240
2437
  // Matcher answers: string[][] keyed by matcher name. Outer/inner min(1)
2241
2438
  // mirrors MatcherBlockSchema invariants in src/ast/matcher.ts.
2439
+ // VisualFraction answers: number[] — one count per color group.
2242
2440
  answers: zod.z.record(
2243
2441
  zod.z.union([
2244
2442
  zod.z.string(),
2245
2443
  zod.z.number(),
2246
- zod.z.array(zod.z.array(zod.z.string().min(1)).min(1)).min(1)
2444
+ zod.z.array(zod.z.array(zod.z.string().min(1)).min(1)).min(1),
2445
+ zod.z.array(zod.z.number().int().nonnegative())
2247
2446
  ])
2248
2447
  ).optional(),
2249
2448
  expression: zod.z.string().optional(),
@@ -2394,13 +2593,15 @@ function toJSON(ast) {
2394
2593
  widgets[inline.name] = {
2395
2594
  type: JSON_TYPES.INLINE_INPUT,
2396
2595
  inputType: INPUT_TYPES.STRING,
2397
- length: inline.length
2596
+ length: inline.length,
2597
+ placeholder: inline.placeholder
2398
2598
  };
2399
2599
  } else {
2400
2600
  widgets[inline.name] = {
2401
2601
  type: JSON_TYPES.INLINE_INPUT,
2402
2602
  inputType: INPUT_TYPES.NUMERIC,
2403
- digits: inline.digits
2603
+ digits: inline.digits,
2604
+ placeholder: inline.placeholder
2404
2605
  };
2405
2606
  }
2406
2607
  if (inline.answer !== void 0) {
@@ -2412,12 +2613,29 @@ function toJSON(ast) {
2412
2613
  type: JSON_TYPES.FRACTION,
2413
2614
  numerator: inline.numerator ?? null,
2414
2615
  denominator: inline.denominator ?? null,
2415
- simplified: inline.simplified ?? true
2616
+ simplified: inline.simplified ?? true,
2617
+ placeholder: inline.placeholder
2416
2618
  };
2417
2619
  if (inline.answer !== void 0) {
2418
2620
  answers[inline.name] = inline.answer;
2419
2621
  }
2420
2622
  }
2623
+ if (inline.type === AST_TYPES.VISUAL_FRACTION) {
2624
+ const answerArr = inline.answer ?? [];
2625
+ widgets[inline.name] = {
2626
+ type: JSON_TYPES.VISUAL_FRACTION,
2627
+ shape: inline.shape,
2628
+ divisions: inline.divisions,
2629
+ filled: inline.filled ?? [],
2630
+ answer: answerArr,
2631
+ ...inline.orientation !== void 0 && { orientation: inline.orientation },
2632
+ ...inline.label !== void 0 && { label: inline.label },
2633
+ ...inline.size !== void 0 && { size: inline.size }
2634
+ };
2635
+ if (answerArr.length > 0) {
2636
+ answers[inline.name] = answerArr;
2637
+ }
2638
+ }
2421
2639
  }
2422
2640
  for (const block of ast.lines) {
2423
2641
  if (block.type === AST_TYPES.RADIO_OPTIONS) {
@@ -2444,7 +2662,8 @@ function toJSON(ast) {
2444
2662
  widgets["_numeric_keypad"] = {
2445
2663
  type: JSON_TYPES.NUMERIC_KEYPAD,
2446
2664
  includeOperators: tool.includeOperators,
2447
- includeDecimal: tool.includeDecimal
2665
+ includeDecimal: tool.includeDecimal,
2666
+ style: tool.style
2448
2667
  };
2449
2668
  }
2450
2669
  }
@@ -2526,6 +2745,7 @@ function transformNumberLine(numberLine, idGenerator) {
2526
2745
  ...numberLine.labelVisibility !== void 0 && { labelVisibility: numberLine.labelVisibility },
2527
2746
  ...counterItem !== void 0 && { counter: counterItem },
2528
2747
  ...highlights !== void 0 && { highlights },
2748
+ ...numberLine.dragMode !== void 0 && { dragMode: numberLine.dragMode },
2529
2749
  positions
2530
2750
  };
2531
2751
  return item;
@@ -2658,6 +2878,12 @@ function transformInline(inline, idGenerator) {
2658
2878
  ...inline.width !== void 0 && { width: inline.width },
2659
2879
  ...inline.height !== void 0 && { height: inline.height }
2660
2880
  };
2881
+ case AST_TYPES.VISUAL_FRACTION:
2882
+ return {
2883
+ id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
2884
+ type: JSON_TYPES.WIDGET_REF,
2885
+ widgetId: inline.name
2886
+ };
2661
2887
  default:
2662
2888
  throw new Error(`Unknown inline type: ${inline.type}`);
2663
2889
  }
@@ -8200,6 +8426,11 @@ function areMatcherEqual(expected, result) {
8200
8426
  const normalize2 = (groups) => lodashExports.sortBy(groups.map((g) => lodashExports.sortBy(g)), (g) => g.join("|"));
8201
8427
  return lodashExports.isEqual(normalize2(expected), normalize2(result));
8202
8428
  }
8429
+ function areVisualFractionCountsEqual(expected, result) {
8430
+ if (!Array.isArray(result)) return false;
8431
+ if (expected.length !== result.length) return false;
8432
+ return lodashExports.isEqual(lodashExports.sortBy(expected), lodashExports.sortBy(result));
8433
+ }
8203
8434
  const math$1 = mathjs.create(mathjs.all);
8204
8435
  function collectNumberLines(items) {
8205
8436
  const result = [];
@@ -8248,6 +8479,15 @@ function validateSimple(exercise, userInputs) {
8248
8479
  else allCorrect = false;
8249
8480
  continue;
8250
8481
  }
8482
+ if (widget?.type === JSON_TYPES.VISUAL_FRACTION) {
8483
+ const userValue2 = userInputs[widgetId];
8484
+ const isCorrect2 = areVisualFractionCountsEqual(expected, userValue2);
8485
+ details[widgetId] = { widgetId, userValue: userValue2, isCorrect: isCorrect2 };
8486
+ totalCount++;
8487
+ if (isCorrect2) correctCount++;
8488
+ else allCorrect = false;
8489
+ continue;
8490
+ }
8251
8491
  totalCount++;
8252
8492
  const userValue = userInputs[widgetId];
8253
8493
  const isFractionLenient = widget?.type === JSON_TYPES.FRACTION && widget.simplified === false;
@@ -8754,10 +8994,10 @@ function getOwnerDocument(target) {
8754
8994
  }
8755
8995
  return document;
8756
8996
  }
8757
- const useIsomorphicLayoutEffect = canUseDOM ? React.useLayoutEffect : React.useEffect;
8997
+ const useIsomorphicLayoutEffect$1 = canUseDOM ? React.useLayoutEffect : React.useEffect;
8758
8998
  function useEvent(handler) {
8759
8999
  const handlerRef = React.useRef(handler);
8760
- useIsomorphicLayoutEffect(() => {
9000
+ useIsomorphicLayoutEffect$1(() => {
8761
9001
  handlerRef.current = handler;
8762
9002
  });
8763
9003
  return React.useCallback(function() {
@@ -8785,7 +9025,7 @@ function useLatestValue(value, dependencies) {
8785
9025
  dependencies = [value];
8786
9026
  }
8787
9027
  const valueRef = React.useRef(value);
8788
- useIsomorphicLayoutEffect(() => {
9028
+ useIsomorphicLayoutEffect$1(() => {
8789
9029
  if (valueRef.current !== value) {
8790
9030
  valueRef.current = value;
8791
9031
  }
@@ -10653,9 +10893,9 @@ function useResizeObserver(_ref) {
10653
10893
  return void 0;
10654
10894
  }
10655
10895
  const {
10656
- ResizeObserver
10896
+ ResizeObserver: ResizeObserver2
10657
10897
  } = window;
10658
- return new ResizeObserver(handleResize);
10898
+ return new ResizeObserver2(handleResize);
10659
10899
  },
10660
10900
  // eslint-disable-next-line react-hooks/exhaustive-deps
10661
10901
  [disabled]
@@ -10709,7 +10949,7 @@ function useRect(element, measure, fallbackRect) {
10709
10949
  const resizeObserver = useResizeObserver({
10710
10950
  callback: measureRect
10711
10951
  });
10712
- useIsomorphicLayoutEffect(() => {
10952
+ useIsomorphicLayoutEffect$1(() => {
10713
10953
  measureRect();
10714
10954
  if (element) {
10715
10955
  resizeObserver == null ? void 0 : resizeObserver.observe(element);
@@ -10883,7 +11123,7 @@ function useRects(elements, measure) {
10883
11123
  const resizeObserver = useResizeObserver({
10884
11124
  callback: measureRects
10885
11125
  });
10886
- useIsomorphicLayoutEffect(() => {
11126
+ useIsomorphicLayoutEffect$1(() => {
10887
11127
  resizeObserver == null ? void 0 : resizeObserver.disconnect();
10888
11128
  measureRects();
10889
11129
  elements.forEach((element) => resizeObserver == null ? void 0 : resizeObserver.observe(element));
@@ -11245,7 +11485,7 @@ function useLayoutShiftScrollCompensation(_ref) {
11245
11485
  x: config,
11246
11486
  y: config
11247
11487
  } : config;
11248
- useIsomorphicLayoutEffect(() => {
11488
+ useIsomorphicLayoutEffect$1(() => {
11249
11489
  const disabled = !x && !y;
11250
11490
  if (disabled || !activeNode) {
11251
11491
  initialized.current = false;
@@ -11606,7 +11846,7 @@ const DndContext = /* @__PURE__ */ React.memo(function DndContext2(_ref) {
11606
11846
  }, [draggableNodes, instantiateSensor]);
11607
11847
  const activators = useCombineActivators(sensors, bindActivatorToSensorInstantiator);
11608
11848
  useSensorSetup(sensors);
11609
- useIsomorphicLayoutEffect(() => {
11849
+ useIsomorphicLayoutEffect$1(() => {
11610
11850
  if (activeNodeRect && status === Status.Initializing) {
11611
11851
  setStatus(Status.Initialized);
11612
11852
  }
@@ -11690,7 +11930,7 @@ const DndContext = /* @__PURE__ */ React.memo(function DndContext2(_ref) {
11690
11930
  // eslint-disable-next-line react-hooks/exhaustive-deps
11691
11931
  [overId]
11692
11932
  );
11693
- useIsomorphicLayoutEffect(() => {
11933
+ useIsomorphicLayoutEffect$1(() => {
11694
11934
  sensorContext.current = {
11695
11935
  activatorEvent,
11696
11936
  active,
@@ -11817,7 +12057,7 @@ function useDraggable(_ref) {
11817
12057
  const [activatorNode, setActivatorNodeRef] = useNodeRef();
11818
12058
  const listeners = useSyntheticListeners(activators, id);
11819
12059
  const dataRef = useLatestValue(data);
11820
- useIsomorphicLayoutEffect(
12060
+ useIsomorphicLayoutEffect$1(
11821
12061
  () => {
11822
12062
  draggableNodes.set(id, {
11823
12063
  id,
@@ -11990,7 +12230,7 @@ function AnimationManager(_ref) {
11990
12230
  if (!children && !clonedChildren && previousChildren) {
11991
12231
  setClonedChildren(previousChildren);
11992
12232
  }
11993
- useIsomorphicLayoutEffect(() => {
12233
+ useIsomorphicLayoutEffect$1(() => {
11994
12234
  if (!element) {
11995
12235
  return;
11996
12236
  }
@@ -12672,6 +12912,7 @@ const InlineInput = ({
12672
12912
  inputType,
12673
12913
  length,
12674
12914
  digits,
12915
+ placeholder,
12675
12916
  onChange,
12676
12917
  onFocus
12677
12918
  }) => {
@@ -12717,19 +12958,88 @@ const InlineInput = ({
12717
12958
  onChange: (e) => handleChange(e.target.value, false),
12718
12959
  onFocus: () => onFocus?.(widgetId),
12719
12960
  maxLength: expectedLength,
12720
- style: { width: dynamicWidth }
12961
+ style: { width: dynamicWidth },
12962
+ placeholder
12721
12963
  }
12722
12964
  )
12723
12965
  )
12724
12966
  }
12725
12967
  );
12726
12968
  };
12969
+ const PillBackground = ({
12970
+ width = "100%",
12971
+ minHeight = "72px",
12972
+ fill = "#FFE7B4",
12973
+ children,
12974
+ className = "",
12975
+ style,
12976
+ ...rest
12977
+ }) => {
12978
+ return /* @__PURE__ */ jsxRuntime.jsxs(
12979
+ "div",
12980
+ {
12981
+ className: `pill-bg-wrapper ${className}`.trim(),
12982
+ style: {
12983
+ position: "relative",
12984
+ display: "inline-flex",
12985
+ alignItems: "center",
12986
+ justifyContent: "center",
12987
+ width,
12988
+ minHeight,
12989
+ ...style
12990
+ },
12991
+ ...rest,
12992
+ children: [
12993
+ /* @__PURE__ */ jsxRuntime.jsx(
12994
+ "svg",
12995
+ {
12996
+ viewBox: "0 0 510 108",
12997
+ preserveAspectRatio: "none",
12998
+ xmlns: "http://www.w3.org/2000/svg",
12999
+ "aria-hidden": "true",
13000
+ style: {
13001
+ position: "absolute",
13002
+ inset: 0,
13003
+ width: "100%",
13004
+ height: "100%",
13005
+ display: "block"
13006
+ },
13007
+ children: /* @__PURE__ */ jsxRuntime.jsx(
13008
+ "path",
13009
+ {
13010
+ d: "M439.707 0C457.071 0 472.52 8.196 482.398 20.9297C498.608 26.3308 510 39.1028 510 54C510 68.8966 498.608 81.667 482.399 87.0684C472.521 99.803 457.072 108 439.707 108H70.8975C53.653 108 38.2956 99.9172 28.4092 87.333C11.7719 82.0918 0.00025108 69.1432 0 54C0 38.8562 11.7721 25.906 28.4102 20.665C38.2966 8.08186 53.6537 0 70.8975 0H439.707Z",
13011
+ fill
13012
+ }
13013
+ )
13014
+ }
13015
+ ),
13016
+ /* @__PURE__ */ jsxRuntime.jsx(
13017
+ "div",
13018
+ {
13019
+ style: {
13020
+ position: "relative",
13021
+ zIndex: 1,
13022
+ display: "flex",
13023
+ alignItems: "center",
13024
+ justifyContent: "center",
13025
+ width: "100%",
13026
+ height: "100%",
13027
+ padding: "0 2rem",
13028
+ boxSizing: "border-box"
13029
+ },
13030
+ children
13031
+ }
13032
+ )
13033
+ ]
13034
+ }
13035
+ );
13036
+ };
12727
13037
  const DragOptions = ({
12728
13038
  options,
12729
13039
  onOptionClick
12730
13040
  }) => {
12731
13041
  const { usedOptionIds } = useExerciseDnd();
12732
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-options", children: options.map((option, idx) => {
13042
+ return /* @__PURE__ */ jsxRuntime.jsx(PillBackground, { children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-options", children: options.map((option, idx) => {
12733
13043
  const optionId = `drag-option-${idx}`;
12734
13044
  const optionValue = String(option);
12735
13045
  const isUsed = usedOptionIds.has(optionId);
@@ -12756,7 +13066,7 @@ const DragOptions = ({
12756
13066
  }
12757
13067
  )
12758
13068
  ) }, optionId);
12759
- }) });
13069
+ }) }) });
12760
13070
  };
12761
13071
  const NumericKeypad = ({
12762
13072
  value,
@@ -12796,8 +13106,11 @@ const FractionInput = ({
12796
13106
  values,
12797
13107
  droppedInputs,
12798
13108
  validationStatus = null,
13109
+ numValidationStatus = null,
13110
+ denValidationStatus = null,
12799
13111
  onChange,
12800
- onFocus
13112
+ onFocus,
13113
+ placeholder
12801
13114
  }) => {
12802
13115
  const numKey = getFractionKey(widgetId, "num");
12803
13116
  const denKey = getFractionKey(widgetId, "den");
@@ -12810,11 +13123,12 @@ const FractionInput = ({
12810
13123
  onChange(subKey, data.value, true, String(item.id));
12811
13124
  }
12812
13125
  }, [onChange]);
12813
- const renderSlot = (subKey, fixedValue, currentValue, label) => {
13126
+ const renderSlot = (subKey, fixedValue, currentValue, label, slotStatus) => {
12814
13127
  if (fixedValue !== null) {
12815
13128
  return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-fixed", children: fixedValue });
12816
13129
  }
12817
13130
  const isDroppedHere = droppedInputs.has(subKey);
13131
+ const slotClass = slotStatus ? ` widget--${slotStatus}` : statusClass;
12818
13132
  return /* @__PURE__ */ jsxRuntime.jsx(
12819
13133
  Droppable,
12820
13134
  {
@@ -12825,7 +13139,7 @@ const FractionInput = ({
12825
13139
  children: isDroppedHere && currentValue !== "" ? /* @__PURE__ */ jsxRuntime.jsx(
12826
13140
  "button",
12827
13141
  {
12828
- className: `inline-input-chip fraction-chip${statusClass}`,
13142
+ className: `inline-input-chip fraction-chip${slotClass}`,
12829
13143
  onClick: () => onChange(subKey, "", false),
12830
13144
  title: "Click to remove",
12831
13145
  children: currentValue
@@ -12835,45 +13149,25 @@ const FractionInput = ({
12835
13149
  {
12836
13150
  id: `${subKey}-input`,
12837
13151
  type: "text",
12838
- className: `inline-input fraction-input${statusClass}`,
13152
+ className: `inline-input fraction-input${slotClass}`,
12839
13153
  value: currentValue,
12840
13154
  onChange: (e) => onChange(subKey, e.target.value, false),
12841
13155
  onFocus: () => onFocus?.(subKey),
12842
- maxLength: 4,
12843
- "aria-label": label
13156
+ maxLength: placeholder ? Math.max(20, placeholder.length) : 4,
13157
+ size: placeholder ? placeholder.length : void 0,
13158
+ "aria-label": label,
13159
+ placeholder
12844
13160
  }
12845
13161
  )
12846
13162
  }
12847
13163
  );
12848
13164
  };
12849
13165
  return /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fraction", role: "math", "aria-label": `fraction ${widgetId}`, children: [
12850
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-numerator", children: renderSlot(numKey, numerator, numValue, "numerator") }),
13166
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-numerator", children: renderSlot(numKey, numerator, numValue, "numerator", numValidationStatus) }),
12851
13167
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-bar", "aria-hidden": "true" }),
12852
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-denominator", children: renderSlot(denKey, denominator, denValue, "denominator") })
13168
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-denominator", children: renderSlot(denKey, denominator, denValue, "denominator", denValidationStatus) })
12853
13169
  ] });
12854
13170
  };
12855
- const RadioOptions = ({
12856
- name,
12857
- options,
12858
- value,
12859
- validationStatus = null,
12860
- onChange
12861
- }) => {
12862
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "radio-options", children: options.map((option, idx) => {
12863
- const isSelected = value === option;
12864
- const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
12865
- return /* @__PURE__ */ jsxRuntime.jsx(
12866
- "button",
12867
- {
12868
- className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
12869
- onClick: () => onChange(name, option),
12870
- type: "button",
12871
- children: option
12872
- },
12873
- `${name}-radio-${idx}`
12874
- );
12875
- }) });
12876
- };
12877
13171
  function isLightColor(hex) {
12878
13172
  const clean = hex.replace("#", "");
12879
13173
  const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
@@ -12888,7 +13182,8 @@ function isLightColor(hex) {
12888
13182
  return 1.05 / (L + 0.05) < 4.5;
12889
13183
  }
12890
13184
  function getTextColorForBackground(color) {
12891
- if (color.startsWith("hsl(")) return "#1a1a1a";
13185
+ const hslLightness = parseHslLightness(color);
13186
+ if (hslLightness !== null) return hslLightness >= 60 ? "#1a1a1a" : "#ffffff";
12892
13187
  return isLightColor(color) ? "#1a1a1a" : "#ffffff";
12893
13188
  }
12894
13189
  function generateHslPalette(n) {
@@ -12897,7 +13192,7 @@ function generateHslPalette(n) {
12897
13192
  return `hsl(${hue}, 60%, 72%)`;
12898
13193
  });
12899
13194
  }
12900
- function resolveMatcherPalette(userColors, groupCount) {
13195
+ function resolveGroupPalette(userColors, groupCount) {
12901
13196
  if (groupCount <= 0) return [];
12902
13197
  if (!userColors || userColors.length === 0) {
12903
13198
  return generateHslPalette(groupCount);
@@ -12909,6 +13204,475 @@ function resolveMatcherPalette(userColors, groupCount) {
12909
13204
  const extras = generateHslPalette(needed);
12910
13205
  return [...userColors, ...extras];
12911
13206
  }
13207
+ function resolveDistinctGroupPalette(groupCount, options = {}) {
13208
+ if (groupCount <= 0) return [];
13209
+ const used = new Set((options.excludeColors ?? []).map(normalizeColorToken));
13210
+ const palette = [];
13211
+ for (const color of options.seedColors ?? []) {
13212
+ const token = normalizeColorToken(color);
13213
+ if (used.has(token)) continue;
13214
+ palette.push(color);
13215
+ used.add(token);
13216
+ if (palette.length === groupCount) return palette;
13217
+ }
13218
+ const addCandidates = (candidates) => {
13219
+ for (const color of candidates) {
13220
+ const token = normalizeColorToken(color);
13221
+ if (used.has(token)) continue;
13222
+ palette.push(color);
13223
+ used.add(token);
13224
+ if (palette.length === groupCount) return;
13225
+ }
13226
+ };
13227
+ addCandidates(generateHslPalette(groupCount));
13228
+ let candidateCount = groupCount + 1;
13229
+ while (palette.length < groupCount) {
13230
+ addCandidates(generateHslPalette(candidateCount++));
13231
+ }
13232
+ return palette;
13233
+ }
13234
+ function getGroupClassName(groupIndex) {
13235
+ return `widget-group-${groupIndex + 1}`;
13236
+ }
13237
+ function normalizeColorToken(color) {
13238
+ return color.replace(/\s+/g, "").toLowerCase();
13239
+ }
13240
+ function parseHslLightness(color) {
13241
+ const match = color.match(/^hsl\(\s*[\d.]+\s*,\s*[\d.]+%\s*,\s*([\d.]+)%\s*\)$/i);
13242
+ return match ? Number(match[1]) : null;
13243
+ }
13244
+ function sumCounts(counts) {
13245
+ return counts.reduce((sum, count) => sum + count, 0);
13246
+ }
13247
+ function countsToPartGroups(counts) {
13248
+ return counts.flatMap(
13249
+ (count, groupIndex) => Array.from({ length: count }, () => groupIndex)
13250
+ );
13251
+ }
13252
+ function normalizeCounts(value, expected) {
13253
+ if (!Array.isArray(value)) return expected.map(() => 0);
13254
+ return expected.map((max, i) => Math.max(0, Math.min(max, value[i] ?? 0)));
13255
+ }
13256
+ function serializeCounts(value) {
13257
+ return Array.isArray(value) ? value.join("|") : null;
13258
+ }
13259
+ function countsToAssignments(counts, divisions, filledCount) {
13260
+ const assignments = Array.from({ length: divisions }, () => null);
13261
+ let index = filledCount;
13262
+ counts.forEach((count, groupIndex) => {
13263
+ for (let i = 0; i < count && index < divisions; i++) {
13264
+ assignments[index] = groupIndex;
13265
+ index++;
13266
+ }
13267
+ });
13268
+ return assignments;
13269
+ }
13270
+ function getNextAssignments(assignments, partIndex, groupCount) {
13271
+ if (groupCount === 0) return [...assignments];
13272
+ const current = assignments[partIndex] ?? null;
13273
+ const next = current === null ? 0 : current + 1 >= groupCount ? null : current + 1;
13274
+ const updated = [...assignments];
13275
+ updated[partIndex] = next;
13276
+ return updated;
13277
+ }
13278
+ function assignmentsToCounts(assignments, groupCount) {
13279
+ const counts = Array.from({ length: groupCount }, () => 0);
13280
+ for (const groupIndex of assignments) {
13281
+ if (groupIndex !== null && groupIndex >= 0 && groupIndex < groupCount) {
13282
+ counts[groupIndex] = (counts[groupIndex] ?? 0) + 1;
13283
+ }
13284
+ }
13285
+ return counts;
13286
+ }
13287
+ function useVisualFractionState({
13288
+ widgetId,
13289
+ divisions,
13290
+ filledCount,
13291
+ answer,
13292
+ value,
13293
+ onChange
13294
+ }) {
13295
+ const [partAssignments, setPartAssignments] = React.useState(
13296
+ () => countsToAssignments(normalizeCounts(value, answer), divisions, filledCount)
13297
+ );
13298
+ const lastInternalValueRef = React.useRef(null);
13299
+ const valueKey = serializeCounts(value);
13300
+ React.useEffect(() => {
13301
+ if (valueKey !== null && valueKey === lastInternalValueRef.current) return;
13302
+ lastInternalValueRef.current = null;
13303
+ setPartAssignments(
13304
+ countsToAssignments(normalizeCounts(value, answer), divisions, filledCount)
13305
+ );
13306
+ }, [answer, divisions, filledCount, valueKey]);
13307
+ const handlePartClick = (partIndex) => {
13308
+ if (partIndex < filledCount) return;
13309
+ const updated = getNextAssignments(partAssignments, partIndex, answer.length);
13310
+ const nextCounts = assignmentsToCounts(updated, answer.length);
13311
+ lastInternalValueRef.current = serializeCounts(nextCounts);
13312
+ setPartAssignments(updated);
13313
+ onChange(widgetId, nextCounts);
13314
+ };
13315
+ return { partAssignments, handlePartClick };
13316
+ }
13317
+ const VISUAL_FRACTION_DISPLAY_COLOR = "#AE9CFF";
13318
+ const VISUAL_FRACTION_BORDER_COLOR = "#705DC0";
13319
+ const VISUAL_FRACTION_SVG_SIZE_SCALE = 8;
13320
+ function getCircleSectorProps(index, divisions) {
13321
+ if (divisions <= 1) return { kind: "circle" };
13322
+ const angle = 360 / divisions;
13323
+ const startAngle = -90 + index * angle;
13324
+ const endAngle = startAngle + angle;
13325
+ const start = polarToCartesian(50, 50, 48, endAngle);
13326
+ const end = polarToCartesian(50, 50, 48, startAngle);
13327
+ const largeArcFlag = angle > 180 ? 1 : 0;
13328
+ return {
13329
+ kind: "path",
13330
+ d: [
13331
+ "M",
13332
+ 50,
13333
+ 50,
13334
+ "L",
13335
+ start.x,
13336
+ start.y,
13337
+ "A",
13338
+ 48,
13339
+ 48,
13340
+ 0,
13341
+ largeArcFlag,
13342
+ 0,
13343
+ end.x,
13344
+ end.y,
13345
+ "Z"
13346
+ ].join(" ")
13347
+ };
13348
+ }
13349
+ function polarToCartesian(cx, cy, r, angleInDegrees) {
13350
+ const rad = angleInDegrees * Math.PI / 180;
13351
+ return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
13352
+ }
13353
+ function getEqualTrianglePartPath(index, divisions) {
13354
+ const side = Math.sqrt(divisions);
13355
+ if (!Number.isInteger(side)) {
13356
+ throw new Error(`getEqualTrianglePartPath: divisions (${divisions}) must be a perfect square.`);
13357
+ }
13358
+ const smallSide = 96 / side;
13359
+ const smallHeight = smallSide * Math.sqrt(3) / 2;
13360
+ const totalHeight = side * smallHeight;
13361
+ const offsetY = (100 - totalHeight) / 2;
13362
+ let cursor = 0;
13363
+ for (let row = 0; row < side; row++) {
13364
+ const y = offsetY + row * smallHeight;
13365
+ const rowLeft = 50 - (row + 1) * smallSide / 2;
13366
+ for (let col = 0; col <= row; col++) {
13367
+ if (cursor === index) {
13368
+ const x = rowLeft + col * smallSide;
13369
+ return ["M", x + smallSide / 2, y, "L", x + smallSide, y + smallHeight, "L", x, y + smallHeight, "Z"].join(" ");
13370
+ }
13371
+ cursor++;
13372
+ }
13373
+ for (let col = 0; col < row; col++) {
13374
+ if (cursor === index) {
13375
+ const x = rowLeft + col * smallSide;
13376
+ return ["M", x + smallSide, y + smallHeight, "L", x + smallSide / 2, y, "L", x + smallSide * 1.5, y, "Z"].join(" ");
13377
+ }
13378
+ cursor++;
13379
+ }
13380
+ }
13381
+ return "";
13382
+ }
13383
+ function getGridColumnCount(divisions) {
13384
+ return Math.ceil(Math.sqrt(divisions));
13385
+ }
13386
+ function getInteractiveProps(part, onPartClick) {
13387
+ if (!part.isInteractiveSlot) return {};
13388
+ return {
13389
+ onClick: () => onPartClick(part.index),
13390
+ role: "button",
13391
+ "aria-pressed": part.isSelected,
13392
+ "aria-label": getPartAriaLabel(part),
13393
+ tabIndex: 0,
13394
+ onKeyDown: (event) => {
13395
+ if (event.key === "Enter" || event.key === " ") {
13396
+ event.preventDefault();
13397
+ onPartClick(part.index);
13398
+ }
13399
+ }
13400
+ };
13401
+ }
13402
+ function getPartAriaLabel(part) {
13403
+ return part.groupIndex === null ? `Part ${part.index + 1}, empty` : `Part ${part.index + 1}, group ${part.groupIndex + 1}`;
13404
+ }
13405
+ const BarShape = ({
13406
+ parts,
13407
+ divisions,
13408
+ orientation,
13409
+ onPartClick
13410
+ }) => {
13411
+ const renderedParts = orientation === "vertical" ? [...parts].reverse() : parts;
13412
+ return /* @__PURE__ */ jsxRuntime.jsx(
13413
+ "div",
13414
+ {
13415
+ className: `visual-fraction-widget__parts${orientation === "vertical" ? " visual-fraction-widget__parts--vertical" : " visual-fraction-widget__parts--horizontal"}`,
13416
+ style: { "--visual-fraction-divisions": divisions },
13417
+ children: renderedParts.map((part) => /* @__PURE__ */ jsxRuntime.jsx(
13418
+ "div",
13419
+ {
13420
+ className: part.className,
13421
+ style: part.style,
13422
+ ...getInteractiveProps(part, onPartClick)
13423
+ },
13424
+ part.index
13425
+ ))
13426
+ }
13427
+ );
13428
+ };
13429
+ BarShape.displayName = "BarShape";
13430
+ const GridShape = ({
13431
+ parts,
13432
+ divisions,
13433
+ onPartClick
13434
+ }) => /* @__PURE__ */ jsxRuntime.jsx(
13435
+ "div",
13436
+ {
13437
+ className: "visual-fraction-widget__parts visual-fraction-widget__parts--horizontal",
13438
+ style: {
13439
+ "--visual-fraction-divisions": divisions,
13440
+ "--visual-fraction-grid-cols": getGridColumnCount(divisions),
13441
+ // Safe integer division: zod schema enforces divisions = perfect square.
13442
+ "--visual-fraction-grid-rows": divisions / getGridColumnCount(divisions)
13443
+ },
13444
+ children: parts.map((part) => /* @__PURE__ */ jsxRuntime.jsx(
13445
+ "div",
13446
+ {
13447
+ className: part.className,
13448
+ style: part.style,
13449
+ ...getInteractiveProps(part, onPartClick)
13450
+ },
13451
+ part.index
13452
+ ))
13453
+ }
13454
+ );
13455
+ GridShape.displayName = "GridShape";
13456
+ const CircleShape = ({
13457
+ parts,
13458
+ divisions,
13459
+ onPartClick
13460
+ }) => /* @__PURE__ */ jsxRuntime.jsx(
13461
+ "svg",
13462
+ {
13463
+ className: "visual-fraction-widget__shape visual-fraction-widget__circle",
13464
+ viewBox: "0 0 100 100",
13465
+ children: parts.map((part) => {
13466
+ const sectorProps = getCircleSectorProps(part.index, divisions);
13467
+ const className = `${part.className} visual-fraction-widget__sector`;
13468
+ const interactiveProps = getInteractiveProps(part, onPartClick);
13469
+ return sectorProps.kind === "circle" ? /* @__PURE__ */ jsxRuntime.jsx(
13470
+ "circle",
13471
+ {
13472
+ className,
13473
+ style: part.style,
13474
+ cx: "50",
13475
+ cy: "50",
13476
+ r: "48",
13477
+ ...interactiveProps
13478
+ },
13479
+ part.index
13480
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
13481
+ "path",
13482
+ {
13483
+ className,
13484
+ style: part.style,
13485
+ d: sectorProps.d,
13486
+ ...interactiveProps
13487
+ },
13488
+ part.index
13489
+ );
13490
+ })
13491
+ }
13492
+ );
13493
+ CircleShape.displayName = "CircleShape";
13494
+ const TriangleShape = ({
13495
+ parts,
13496
+ divisions,
13497
+ onPartClick
13498
+ }) => /* @__PURE__ */ jsxRuntime.jsx(
13499
+ "svg",
13500
+ {
13501
+ className: "visual-fraction-widget__shape visual-fraction-widget__triangle",
13502
+ viewBox: "0 0 100 100",
13503
+ children: parts.map((part) => /* @__PURE__ */ jsxRuntime.jsx(
13504
+ "path",
13505
+ {
13506
+ className: `${part.className} visual-fraction-widget__sector`,
13507
+ style: part.style,
13508
+ d: getEqualTrianglePartPath(part.index, divisions),
13509
+ ...getInteractiveProps(part, onPartClick)
13510
+ },
13511
+ part.index
13512
+ ))
13513
+ }
13514
+ );
13515
+ TriangleShape.displayName = "TriangleShape";
13516
+ const ShapeBar = ({ parts, divisions, orientation, onPartClick }) => /* @__PURE__ */ jsxRuntime.jsx(BarShape, { parts, divisions, orientation, onPartClick });
13517
+ const ShapeCircle = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsxRuntime.jsx(CircleShape, { parts, divisions, onPartClick });
13518
+ const ShapeTriangle = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsxRuntime.jsx(TriangleShape, { parts, divisions, onPartClick });
13519
+ const ShapeGrid = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsxRuntime.jsx(GridShape, { parts, divisions, onPartClick });
13520
+ const SHAPE_COMPONENTS = {
13521
+ [VISUAL_FRACTION_SHAPE.BAR]: ShapeBar,
13522
+ [VISUAL_FRACTION_SHAPE.CIRCLE]: ShapeCircle,
13523
+ [VISUAL_FRACTION_SHAPE.TRIANGLE]: ShapeTriangle,
13524
+ [VISUAL_FRACTION_SHAPE.GRID]: ShapeGrid
13525
+ };
13526
+ const VisualFraction = ({
13527
+ widgetId,
13528
+ widget,
13529
+ value,
13530
+ validationStatus = null,
13531
+ onChange
13532
+ }) => {
13533
+ const { shape, divisions, filled, answer, orientation, label } = widget;
13534
+ const filledCount = sumCounts(filled);
13535
+ const filledPartGroups = React.useMemo(() => countsToPartGroups(filled), [filled]);
13536
+ const filledPalette = React.useMemo(() => {
13537
+ if (filled.length === 0) return [];
13538
+ return resolveDistinctGroupPalette(filled.length, {
13539
+ seedColors: [VISUAL_FRACTION_DISPLAY_COLOR],
13540
+ excludeColors: [VISUAL_FRACTION_BORDER_COLOR]
13541
+ });
13542
+ }, [filled]);
13543
+ const answerPalette = React.useMemo(() => {
13544
+ if (answer.length === 0) return [];
13545
+ return resolveDistinctGroupPalette(answer.length, {
13546
+ excludeColors: [VISUAL_FRACTION_BORDER_COLOR, ...filledPalette]
13547
+ });
13548
+ }, [answer, filledPalette]);
13549
+ const { partAssignments, handlePartClick } = useVisualFractionState({
13550
+ widgetId,
13551
+ divisions,
13552
+ filledCount,
13553
+ answer,
13554
+ value,
13555
+ onChange
13556
+ });
13557
+ const parts = React.useMemo(
13558
+ () => Array.from({ length: divisions }, (_, i) => {
13559
+ let partClass = "visual-fraction-widget__part visual-fraction-widget__part--empty";
13560
+ let groupIdx = null;
13561
+ if (i < filledCount) {
13562
+ groupIdx = filledPartGroups[i] ?? 0;
13563
+ partClass = "visual-fraction-widget__part visual-fraction-widget__part--filled";
13564
+ } else if (partAssignments[i] != null) {
13565
+ groupIdx = partAssignments[i];
13566
+ partClass = "visual-fraction-widget__part visual-fraction-widget__part--answer";
13567
+ }
13568
+ if (groupIdx !== null) {
13569
+ partClass += ` visual-fraction-widget__part--group-${groupIdx + 1} ${getGroupClassName(groupIdx)}`;
13570
+ }
13571
+ const colorKind = i < filledCount ? "filled" : "answer";
13572
+ const palette = colorKind === "filled" ? filledPalette : answerPalette;
13573
+ const fallbackColor = palette[groupIdx ?? 0];
13574
+ const style = groupIdx !== null ? {
13575
+ "--visual-fraction-part-color": `var(--visual-fraction-${colorKind}-group-${groupIdx + 1}-color, ${fallbackColor})`,
13576
+ "--visual-fraction-part-text-color": `var(--visual-fraction-${colorKind}-group-${groupIdx + 1}-text-color, ${getTextColorForBackground(fallbackColor)})`
13577
+ } : void 0;
13578
+ return {
13579
+ index: i,
13580
+ className: partClass,
13581
+ style,
13582
+ // A slot is interactive when there are answer groups AND it falls in the non-filled range.
13583
+ // answer.length counts groups (not total slots); the state hook handles per-slot cycling.
13584
+ isInteractiveSlot: answer.length > 0 && i >= filledCount,
13585
+ isSelected: groupIdx !== null,
13586
+ groupIndex: groupIdx
13587
+ };
13588
+ }),
13589
+ [divisions, filledCount, filledPartGroups, partAssignments, answer, filledPalette, answerPalette]
13590
+ );
13591
+ const statusClass = validationStatus ? ` visual-fraction-widget--${validationStatus} widget--${validationStatus}` : "";
13592
+ const rootStyle = React.useMemo(() => getSizeStyle(widget.size), [widget.size, shape]);
13593
+ const ShapeComponent = SHAPE_COMPONENTS[shape];
13594
+ return /* @__PURE__ */ jsxRuntime.jsxs(
13595
+ "div",
13596
+ {
13597
+ className: `visual-fraction-widget visual-fraction-widget--${shape}${statusClass}`,
13598
+ style: rootStyle,
13599
+ role: "group",
13600
+ "aria-label": `${divisions} part ${shape} fraction`,
13601
+ children: [
13602
+ /* @__PURE__ */ jsxRuntime.jsx(
13603
+ ShapeComponent,
13604
+ {
13605
+ parts,
13606
+ divisions,
13607
+ orientation: orientation ?? "horizontal",
13608
+ onPartClick: handlePartClick
13609
+ }
13610
+ ),
13611
+ label && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "visual-fraction-widget__label", children: label })
13612
+ ]
13613
+ }
13614
+ );
13615
+ };
13616
+ VisualFraction.displayName = "VisualFraction";
13617
+ function getSizeStyle(size, _shape) {
13618
+ if (size === void 0) return void 0;
13619
+ return {
13620
+ "--visual-fraction-size": `${size * VISUAL_FRACTION_SVG_SIZE_SCALE}rem`
13621
+ };
13622
+ }
13623
+ const RadioOptions = ({
13624
+ name,
13625
+ options,
13626
+ value,
13627
+ validationStatus = null,
13628
+ onChange
13629
+ }) => {
13630
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "radio-options", children: options.map((option, idx) => {
13631
+ const isSelected = value === option;
13632
+ const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
13633
+ return /* @__PURE__ */ jsxRuntime.jsx(
13634
+ "button",
13635
+ {
13636
+ className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
13637
+ onClick: () => onChange(name, option),
13638
+ type: "button",
13639
+ children: option
13640
+ },
13641
+ `${name}-radio-${idx}`
13642
+ );
13643
+ }) });
13644
+ };
13645
+ const Btn = ({
13646
+ label,
13647
+ extraClass = "keypad-btn-digit",
13648
+ onClick
13649
+ }) => /* @__PURE__ */ jsxRuntime.jsx("button", { className: `keypad-btn ${extraClass}`, onClick, children: /* @__PURE__ */ jsxRuntime.jsx("span", { children: label }) });
13650
+ const DiamondKeypad = ({ value, onChange }) => {
13651
+ const append = (ch) => onChange(value + ch);
13652
+ const backspace = () => onChange(value.slice(0, -1));
13653
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "diamond-keypad", children: [
13654
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "diamond-keypad-row", children: [
13655
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "1", onClick: () => append("1") }),
13656
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "2", onClick: () => append("2") }),
13657
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "3", onClick: () => append("3") })
13658
+ ] }),
13659
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "diamond-keypad-row", children: [
13660
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "4", onClick: () => append("4") }),
13661
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "5", onClick: () => append("5") }),
13662
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "6", onClick: () => append("6") }),
13663
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "7", onClick: () => append("7") })
13664
+ ] }),
13665
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "diamond-keypad-row", children: [
13666
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "8", onClick: () => append("8") }),
13667
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "9", onClick: () => append("9") }),
13668
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "0", onClick: () => append("0") })
13669
+ ] }),
13670
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "diamond-keypad-row", children: [
13671
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: ".", extraClass: "keypad-btn-decimal", onClick: () => append(".") }),
13672
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "⌫", extraClass: "keypad-btn-backspace", onClick: backspace })
13673
+ ] })
13674
+ ] });
13675
+ };
12912
13676
  function collectItemIds(rows) {
12913
13677
  return rows.flatMap((row) => row.items.map((item) => item.itemId));
12914
13678
  }
@@ -12950,7 +13714,7 @@ const MatcherWidget = ({
12950
13714
  const { rows, colors: userColors, groupCount } = widget;
12951
13715
  const widgetsMap = widgets ?? EMPTY_WIDGETS;
12952
13716
  const palette = React.useMemo(
12953
- () => resolveMatcherPalette(userColors, groupCount),
13717
+ () => resolveGroupPalette(userColors, groupCount),
12954
13718
  [userColors, groupCount]
12955
13719
  );
12956
13720
  const textColors = React.useMemo(
@@ -12966,13 +13730,21 @@ const MatcherWidget = ({
12966
13730
  setAssignments((prev) => {
12967
13731
  const current = prev[itemId] ?? null;
12968
13732
  const next = current === null ? 0 : current + 1 >= groupCount ? null : current + 1;
12969
- const updated = { ...prev, [itemId]: next };
12970
- onChange(widgetId, buildGroups(updated));
12971
- return updated;
13733
+ return { ...prev, [itemId]: next };
12972
13734
  });
12973
13735
  },
12974
- [groupCount, onChange, widgetId]
13736
+ [groupCount]
12975
13737
  );
13738
+ const onChangeRef = React.useRef(onChange);
13739
+ onChangeRef.current = onChange;
13740
+ const isMounted = React.useRef(false);
13741
+ React.useEffect(() => {
13742
+ if (!isMounted.current) {
13743
+ isMounted.current = true;
13744
+ return;
13745
+ }
13746
+ onChangeRef.current(widgetId, buildGroups(assignments));
13747
+ }, [assignments, widgetId]);
12976
13748
  const maxCols = React.useMemo(
12977
13749
  () => rows.length === 0 ? 1 : Math.max(...rows.map((r) => r.items.length)),
12978
13750
  [rows]
@@ -12993,9 +13765,9 @@ const MatcherWidget = ({
12993
13765
  {
12994
13766
  type: "button",
12995
13767
  className: `matcher-card${isSelected ? " matcher-card--selected" : ""}`,
12996
- style: color ? {
12997
- "--matcher-card-color": color,
12998
- "--matcher-card-text-color": textColors[colorIdx]
13768
+ style: colorIdx !== null && color ? {
13769
+ "--matcher-card-color": `var(--matcher-group-${colorIdx + 1}-color, ${color})`,
13770
+ "--matcher-card-text-color": `var(--matcher-group-${colorIdx + 1}-text-color, ${textColors[colorIdx]})`
12999
13771
  } : void 0,
13000
13772
  onClick: () => handleCardClick(item.itemId),
13001
13773
  "aria-pressed": isSelected,
@@ -13037,6 +13809,9 @@ function isFractionWidget(widget) {
13037
13809
  function isMatcherWidget(widget) {
13038
13810
  return widget.type === "matcher";
13039
13811
  }
13812
+ function isVisualFractionWidget(widget) {
13813
+ return widget.type === "visual-fraction";
13814
+ }
13040
13815
  const WidgetRenderer = ({
13041
13816
  widget,
13042
13817
  widgetId,
@@ -13044,6 +13819,8 @@ const WidgetRenderer = ({
13044
13819
  values = {},
13045
13820
  isDropped = false,
13046
13821
  validationStatus = null,
13822
+ numValidationStatus = null,
13823
+ denValidationStatus = null,
13047
13824
  droppedInputs = /* @__PURE__ */ new Set(),
13048
13825
  widgets,
13049
13826
  onChange,
@@ -13062,6 +13839,7 @@ const WidgetRenderer = ({
13062
13839
  inputType: widget.inputType,
13063
13840
  length: widget.inputType === "string" ? widget.length : void 0,
13064
13841
  digits: widget.inputType === "numeric" ? widget.digits : void 0,
13842
+ placeholder: widget.placeholder,
13065
13843
  onChange,
13066
13844
  onFocus: onInputFocus
13067
13845
  }
@@ -13090,14 +13868,24 @@ const WidgetRenderer = ({
13090
13868
  );
13091
13869
  }
13092
13870
  if (isNumericKeypadWidget(widget)) {
13871
+ const keypadOnChange = onKeypadChange || (() => {
13872
+ });
13873
+ if (widget.style === "classic") {
13874
+ return /* @__PURE__ */ jsxRuntime.jsx(
13875
+ NumericKeypad,
13876
+ {
13877
+ value: strValue,
13878
+ includeOperators: widget.includeOperators,
13879
+ includeDecimal: widget.includeDecimal,
13880
+ onChange: keypadOnChange
13881
+ }
13882
+ );
13883
+ }
13093
13884
  return /* @__PURE__ */ jsxRuntime.jsx(
13094
- NumericKeypad,
13885
+ DiamondKeypad,
13095
13886
  {
13096
13887
  value: strValue,
13097
- includeOperators: widget.includeOperators,
13098
- includeDecimal: widget.includeDecimal,
13099
- onChange: onKeypadChange || (() => {
13100
- })
13888
+ onChange: keypadOnChange
13101
13889
  }
13102
13890
  );
13103
13891
  }
@@ -13111,8 +13899,11 @@ const WidgetRenderer = ({
13111
13899
  values,
13112
13900
  droppedInputs,
13113
13901
  validationStatus,
13902
+ numValidationStatus,
13903
+ denValidationStatus,
13114
13904
  onChange,
13115
- onFocus: onInputFocus
13905
+ onFocus: onInputFocus,
13906
+ placeholder: widget.placeholder
13116
13907
  }
13117
13908
  );
13118
13909
  }
@@ -13129,6 +13920,18 @@ const WidgetRenderer = ({
13129
13920
  }
13130
13921
  );
13131
13922
  }
13923
+ if (isVisualFractionWidget(widget)) {
13924
+ return /* @__PURE__ */ jsxRuntime.jsx(
13925
+ VisualFraction,
13926
+ {
13927
+ widgetId,
13928
+ widget,
13929
+ value: Array.isArray(value) && value.every((v) => typeof v === "number") ? value : void 0,
13930
+ validationStatus,
13931
+ onChange: (id, nextValue) => onChange(id, nextValue)
13932
+ }
13933
+ );
13934
+ }
13132
13935
  return null;
13133
13936
  };
13134
13937
  const EPSILON = 10 ** -INTERNAL_PRECISION;
@@ -13150,7 +13953,7 @@ const getMajorTicks = (min, max, step) => {
13150
13953
  const count = Math.floor((max - min) / step);
13151
13954
  const ticks = Array.from({ length: count + 1 }, (_, i) => cleanFloat(min + i * step));
13152
13955
  const lastTick = ticks[ticks.length - 1];
13153
- if (!almostEqual(lastTick, max)) {
13956
+ if (roundTick(lastTick) !== roundTick(max)) {
13154
13957
  ticks.push(max);
13155
13958
  } else {
13156
13959
  ticks[ticks.length - 1] = max;
@@ -13631,6 +14434,102 @@ const CounterControls = ({
13631
14434
  ] }),
13632
14435
  counter.name && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "number-line-counter-label", children: counter.name })
13633
14436
  ] });
14437
+ const CONTENT_ROW_EXTRA_SPACE = 8;
14438
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
14439
+ function useMeasuredPositionContentHeight(positionIds) {
14440
+ const [heightPx, setHeightPx] = React.useState(null);
14441
+ const elementsRef = React.useRef(/* @__PURE__ */ new Map());
14442
+ const refCallbacksRef = React.useRef(/* @__PURE__ */ new Map());
14443
+ const observerRef = React.useRef(null);
14444
+ const measureFrameRef = React.useRef(null);
14445
+ const idsKey = positionIds.join("|");
14446
+ const runMeasure = React.useCallback(() => {
14447
+ let maxHeight = 0;
14448
+ for (const element of elementsRef.current.values()) {
14449
+ const rectHeight = element.getBoundingClientRect().height;
14450
+ const measuredHeight = Math.max(rectHeight, element.offsetHeight);
14451
+ maxHeight = Math.max(maxHeight, measuredHeight);
14452
+ }
14453
+ const nextHeight = maxHeight > 0 ? Math.ceil(maxHeight + CONTENT_ROW_EXTRA_SPACE) : null;
14454
+ setHeightPx((current) => current === nextHeight ? current : nextHeight);
14455
+ }, []);
14456
+ const measure = React.useCallback(() => {
14457
+ if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") {
14458
+ runMeasure();
14459
+ return;
14460
+ }
14461
+ if (measureFrameRef.current !== null) return;
14462
+ measureFrameRef.current = window.requestAnimationFrame(() => {
14463
+ measureFrameRef.current = null;
14464
+ runMeasure();
14465
+ });
14466
+ }, [runMeasure]);
14467
+ React.useEffect(
14468
+ () => () => {
14469
+ if (measureFrameRef.current !== null && typeof window !== "undefined") {
14470
+ window.cancelAnimationFrame(measureFrameRef.current);
14471
+ measureFrameRef.current = null;
14472
+ }
14473
+ },
14474
+ []
14475
+ );
14476
+ const getPositionContentRef = React.useCallback(
14477
+ (id) => {
14478
+ const cachedRef = refCallbacksRef.current.get(id);
14479
+ if (cachedRef) return cachedRef;
14480
+ const refCallback = (node) => {
14481
+ const current = elementsRef.current.get(id);
14482
+ if (current && observerRef.current) {
14483
+ observerRef.current.unobserve(current);
14484
+ }
14485
+ if (node) {
14486
+ elementsRef.current.set(id, node);
14487
+ observerRef.current?.observe(node);
14488
+ } else {
14489
+ elementsRef.current.delete(id);
14490
+ }
14491
+ measure();
14492
+ };
14493
+ refCallbacksRef.current.set(id, refCallback);
14494
+ return refCallback;
14495
+ },
14496
+ [measure]
14497
+ );
14498
+ React.useEffect(() => {
14499
+ const liveIds = new Set(positionIds);
14500
+ for (const id of elementsRef.current.keys()) {
14501
+ if (!liveIds.has(id)) {
14502
+ elementsRef.current.delete(id);
14503
+ }
14504
+ }
14505
+ for (const id of refCallbacksRef.current.keys()) {
14506
+ if (!liveIds.has(id)) {
14507
+ refCallbacksRef.current.delete(id);
14508
+ }
14509
+ }
14510
+ measure();
14511
+ }, [idsKey, measure]);
14512
+ useIsomorphicLayoutEffect(() => {
14513
+ if (typeof ResizeObserver === "undefined") {
14514
+ measure();
14515
+ return void 0;
14516
+ }
14517
+ const observer = new ResizeObserver(measure);
14518
+ observerRef.current = observer;
14519
+ for (const element of elementsRef.current.values()) {
14520
+ observer.observe(element);
14521
+ }
14522
+ measure();
14523
+ return () => {
14524
+ observer.disconnect();
14525
+ observerRef.current = null;
14526
+ };
14527
+ }, [idsKey, measure]);
14528
+ return {
14529
+ positionContentHeight: heightPx === null ? void 0 : `${heightPx}px`,
14530
+ getPositionContentRef
14531
+ };
14532
+ }
13634
14533
  const NumberLineRenderer = ({
13635
14534
  item,
13636
14535
  widgets,
@@ -13665,7 +14564,7 @@ const NumberLineRenderer = ({
13665
14564
  return step;
13666
14565
  }, [counter, counterCount, min, max, step]);
13667
14566
  const majorTicks = React.useMemo(
13668
- () => getMajorTicks(min, max, effectiveStep).map(roundTick),
14567
+ () => [...new Set(getMajorTicks(min, max, effectiveStep).map(roundTick))],
13669
14568
  [min, max, effectiveStep]
13670
14569
  );
13671
14570
  const minorValues = React.useMemo(
@@ -13674,28 +14573,38 @@ const NumberLineRenderer = ({
13674
14573
  );
13675
14574
  const showArrowRight = lineStyle === "arrow-right" || lineStyle === "arrow-both";
13676
14575
  const showArrowLeft = lineStyle === "arrow-left" || lineStyle === "arrow-both";
13677
- const isDragMode = Boolean(widgets[DRAG_OPTIONS_WIDGET_ID]);
14576
+ const hasDragOptions = Boolean(widgets[DRAG_OPTIONS_WIDGET_ID]);
14577
+ const isAxisDragMode = hasDragOptions && item.dragMode === "axis";
13678
14578
  const inputTickSet = React.useMemo(() => {
13679
- if (isDragMode) return /* @__PURE__ */ new Set();
14579
+ if (isAxisDragMode) return /* @__PURE__ */ new Set();
13680
14580
  return new Set(
13681
14581
  positions.filter((pos) => getPositionWidgetId(pos.content) !== null).map((pos) => roundTick(pos.value))
13682
14582
  );
13683
- }, [isDragMode, positions]);
14583
+ }, [isAxisDragMode, positions]);
13684
14584
  const visiblePositions = React.useMemo(
13685
- () => positions.filter((pos) => !isDragMode || getPositionWidgetId(pos.content) === null),
13686
- [isDragMode, positions]
14585
+ () => positions.filter((pos) => !isAxisDragMode || getPositionWidgetId(pos.content) === null),
14586
+ [isAxisDragMode, positions]
13687
14587
  );
14588
+ const visiblePositionIds = React.useMemo(
14589
+ () => visiblePositions.map((pos) => pos.id),
14590
+ [visiblePositions]
14591
+ );
14592
+ const { positionContentHeight, getPositionContentRef } = useMeasuredPositionContentHeight(visiblePositionIds);
13688
14593
  return /* @__PURE__ */ jsxRuntime.jsxs(
13689
14594
  "div",
13690
14595
  {
13691
- className: `number-line${isDragMode ? " number-line--drag-mode" : ""}${lineStyle === "line" ? " number-line--style-line" : ""}`,
13692
- style: { [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%` },
14596
+ className: `number-line${isAxisDragMode ? " number-line--drag-mode" : ""}${lineStyle === "line" ? " number-line--style-line" : ""}`,
14597
+ style: {
14598
+ [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%`,
14599
+ ...positionContentHeight && { "--nl-position-content-height": positionContentHeight }
14600
+ },
13693
14601
  role: "group",
13694
14602
  "aria-label": `Number line from ${min} to ${max}`,
13695
14603
  children: [
13696
14604
  visiblePositions.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-content-row", children: visiblePositions.map((pos) => /* @__PURE__ */ jsxRuntime.jsx(
13697
14605
  "div",
13698
14606
  {
14607
+ ref: getPositionContentRef(pos.id),
13699
14608
  className: "number-line-position-content",
13700
14609
  style: { left: `${toPercent(pos.value, min, max)}%` },
13701
14610
  children: pos.content.map((subItem) => /* @__PURE__ */ jsxRuntime.jsx(
@@ -13714,7 +14623,7 @@ const NumberLineRenderer = ({
13714
14623
  },
13715
14624
  pos.id
13716
14625
  )) }),
13717
- isDragMode ? /* @__PURE__ */ jsxRuntime.jsx(
14626
+ isAxisDragMode ? /* @__PURE__ */ jsxRuntime.jsx(
13718
14627
  FreeDragAxis,
13719
14628
  {
13720
14629
  axisDropId: `${item.id}--nl-axis`,
@@ -13825,6 +14734,7 @@ function groupLineItems(items) {
13825
14734
  }
13826
14735
  return groups;
13827
14736
  }
14737
+ const toValidationStatus = (v) => v === true ? "correct" : v === false ? "incorrect" : null;
13828
14738
  const LayoutRenderer = ({
13829
14739
  item,
13830
14740
  widgets,
@@ -13840,8 +14750,10 @@ const LayoutRenderer = ({
13840
14750
  const hasArabic = isArabic(item.value);
13841
14751
  return /* @__PURE__ */ jsxRuntime.jsx("span", { className: `question-text ${hasArabic ? "question-text-arabic" : ""}`, children: item.value });
13842
14752
  }
13843
- case JSON_TYPES.TEXT:
13844
- return /* @__PURE__ */ jsxRuntime.jsx("span", { className: `text-item ${isArabic(item.value) ? "text-item-arabic" : "text-item-not-arabic"}`, children: item.value });
14753
+ case JSON_TYPES.TEXT: {
14754
+ const isNum = /^[\d.,\s]+$/.test(item.value);
14755
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: `text-item ${isNum ? "text-item-number" : ""} ${isArabic(item.value) ? "text-item-arabic" : "text-item-not-arabic"}`, children: item.value });
14756
+ }
13845
14757
  case JSON_TYPES.HIGHLIGHTED_TEXT: {
13846
14758
  const hasArabic = isArabic(item.value);
13847
14759
  const dirAttr = !hasArabic ? "ltr" : void 0;
@@ -13898,7 +14810,7 @@ const LayoutRenderer = ({
13898
14810
  if (!widget) return null;
13899
14811
  const isCorrect = validationDetails?.[item.widgetId];
13900
14812
  const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
13901
- const widgetKey = widget.type === JSON_TYPES.MATCHER ? `${item.widgetId}-${resetKey}` : item.widgetId;
14813
+ const widgetKey = widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.VISUAL_FRACTION ? `${item.widgetId}-${resetKey}` : item.widgetId;
13902
14814
  return /* @__PURE__ */ jsxRuntime.jsx(
13903
14815
  WidgetRenderer,
13904
14816
  {
@@ -13908,6 +14820,8 @@ const LayoutRenderer = ({
13908
14820
  values,
13909
14821
  isDropped: droppedInputs.has(item.widgetId),
13910
14822
  validationStatus,
14823
+ numValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__num`]),
14824
+ denValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__den`]),
13911
14825
  droppedInputs,
13912
14826
  widgets,
13913
14827
  onChange,
@@ -14013,6 +14927,27 @@ const ExerciseRenderer = React.forwardRef(({
14013
14927
  for (const [id, v] of Object.entries(result.details)) {
14014
14928
  details[id] = v.isCorrect;
14015
14929
  }
14930
+ for (const [widgetId, widget] of Object.entries(json.widgets)) {
14931
+ if (widget.type !== JSON_TYPES.FRACTION) continue;
14932
+ if (!(widgetId in details)) continue;
14933
+ const answer = json.validation?.answers?.[widgetId];
14934
+ if (!answer) continue;
14935
+ const [expectedNum, expectedDen] = answer.split("/", 2);
14936
+ if (!expectedNum || !expectedDen) continue;
14937
+ if (details[widgetId]) {
14938
+ if (widget.numerator === null) details[`${widgetId}__num`] = true;
14939
+ if (widget.denominator === null) details[`${widgetId}__den`] = true;
14940
+ } else {
14941
+ if (widget.numerator === null) {
14942
+ const u = values[`${widgetId}__num`];
14943
+ details[`${widgetId}__num`] = typeof u === "string" && u !== "" && normalize(u) === normalize(expectedNum);
14944
+ }
14945
+ if (widget.denominator === null) {
14946
+ const u = values[`${widgetId}__den`];
14947
+ details[`${widgetId}__den`] = typeof u === "string" && u !== "" && normalize(u) === normalize(expectedDen);
14948
+ }
14949
+ }
14950
+ }
14016
14951
  setValidationDetails(details);
14017
14952
  onSubmit?.(values);
14018
14953
  return result;
@@ -14302,9 +15237,14 @@ exports.TextBlockSchema = TextBlockSchema;
14302
15237
  exports.TextItemSchema = TextItemSchema;
14303
15238
  exports.ToolNodeSchema = ToolNodeSchema;
14304
15239
  exports.TransformError = TransformError;
15240
+ exports.VISUAL_FRACTION_DEFAULT_SIZE = VISUAL_FRACTION_DEFAULT_SIZE;
15241
+ exports.VISUAL_FRACTION_MAX_DIVISIONS = VISUAL_FRACTION_MAX_DIVISIONS;
15242
+ exports.VISUAL_FRACTION_SHAPE = VISUAL_FRACTION_SHAPE;
15243
+ exports.VISUAL_FRACTION_SHAPES = VISUAL_FRACTION_SHAPES;
14305
15244
  exports.ValidationError = ValidationError;
14306
15245
  exports.ValidationJSONSchema = ValidationJSONSchema;
14307
15246
  exports.ValidationNodeSchema = ValidationNodeSchema;
15247
+ exports.VisualFractionInlineSchema = VisualFractionInlineSchema;
14308
15248
  exports.WidgetJSONSchema = WidgetJSONSchema;
14309
15249
  exports.WidgetRefSchema = WidgetRefSchema;
14310
15250
  exports.WidgetRenderer = WidgetRenderer;
@@ -14329,6 +15269,7 @@ exports.createSimpleValidation = createSimpleValidation;
14329
15269
  exports.createSpace = createSpace;
14330
15270
  exports.createStringInput = createStringInput;
14331
15271
  exports.createTextBlock = createTextBlock;
15272
+ exports.createVisualFraction = createVisualFraction;
14332
15273
  exports.evaluate = evaluate;
14333
15274
  exports.extractExpressionVariables = extractVariables;
14334
15275
  exports.findValidCombination = findValidCombination;
@@ -14372,6 +15313,7 @@ exports.isTextItem = isTextItem$1;
14372
15313
  exports.isToolNode = isToolNode;
14373
15314
  exports.isValidExpression = isValidExpression;
14374
15315
  exports.isValidationNode = isValidationNode;
15316
+ exports.isVisualFractionInline = isVisualFractionInline;
14375
15317
  exports.isWidgetRef = isWidgetRef;
14376
15318
  exports.parse = parse;
14377
15319
  exports.parseFractionKey = parseFractionKey;
@@ -14388,6 +15330,7 @@ exports.validateExerciseJSONSafe = validateExerciseJSONSafe;
14388
15330
  exports.validateExpression = validateExpression;
14389
15331
  exports.validateInputFormats = validateInputFormats;
14390
15332
  exports.validateSimple = validateSimple;
15333
+ exports.validateVisualFractionCapacity = validateVisualFractionCapacity;
14391
15334
  exports.validateWidgetJSON = validateWidgetJSON;
14392
15335
  exports.validateWidgetJSONSafe = validateWidgetJSONSafe;
14393
15336
  //# sourceMappingURL=index.js.map