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