@mathscan/math-exercise-engine 1.0.15 → 1.0.16

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 = {
@@ -268,6 +272,10 @@ const HIGHLIGHT = chevrotain.createToken({
268
272
  name: "HIGHLIGHT",
269
273
  pattern: /HIGHLIGHT(?!_TEXT)/
270
274
  });
275
+ const VISUAL_FRACTION = chevrotain.createToken({
276
+ name: "VISUAL_FRACTION",
277
+ pattern: /VISUAL_FRACTION/
278
+ });
271
279
  const FRACTION = chevrotain.createToken({
272
280
  name: "FRACTION",
273
281
  pattern: /FRACTION/
@@ -397,6 +405,7 @@ const allTokens = [
397
405
  CELL,
398
406
  COUNTER,
399
407
  HIGHLIGHT,
408
+ VISUAL_FRACTION,
400
409
  FRACTION,
401
410
  MATCHER,
402
411
  ITEM,
@@ -862,6 +871,8 @@ class MathExerciseParser extends chevrotain.CstParser {
862
871
  { ALT: () => this.SUBRULE(this.fraction) },
863
872
  // IMAGE (inline image element)
864
873
  { ALT: () => this.SUBRULE(this.image) },
874
+ // VISUAL_FRACTION — always allowed; display-only when answer=[] or absence of answer parameter, interactive when answer=[...] with values.
875
+ { ALT: () => this.SUBRULE(this.visualFraction) },
865
876
  // W_S_I (inline input widget) — excluded in display-only contexts (e.g. MATCHER ITEM)
866
877
  { GATE: () => !displayOnly, ALT: () => this.SUBRULE(this.inlineInput) }
867
878
  ]);
@@ -899,6 +910,41 @@ class MathExerciseParser extends chevrotain.CstParser {
899
910
  this.SUBRULE(this.parameterList);
900
911
  this.CONSUME(RBracket);
901
912
  });
913
+ this.visualFraction = this.RULE("visualFraction", () => {
914
+ this.CONSUME(VISUAL_FRACTION);
915
+ this.CONSUME(LBracket);
916
+ this.SUBRULE(this.parameterList);
917
+ this.CONSUME(RBracket);
918
+ });
919
+ this.numberArray = this.RULE("numberArray", () => {
920
+ this.CONSUME(LBracket);
921
+ this.MANY(() => {
922
+ this.CONSUME(Space);
923
+ });
924
+ this.OPTION(() => {
925
+ this.CONSUME(NumberLiteral, { LABEL: "items" });
926
+ this.MANY2(() => {
927
+ this.MANY3(() => {
928
+ this.CONSUME2(Space);
929
+ });
930
+ this.CONSUME(Comma);
931
+ this.MANY4(() => {
932
+ this.CONSUME3(Space);
933
+ });
934
+ this.CONSUME2(NumberLiteral, { LABEL: "items" });
935
+ });
936
+ this.OPTION2(() => {
937
+ this.MANY5(() => {
938
+ this.CONSUME4(Space);
939
+ });
940
+ this.CONSUME2(Comma);
941
+ });
942
+ });
943
+ this.MANY6(() => {
944
+ this.CONSUME5(Space);
945
+ });
946
+ this.CONSUME(RBracket);
947
+ });
902
948
  this.inlineInput = this.RULE("inlineInput", () => {
903
949
  this.OR([
904
950
  { ALT: () => this.CONSUME(W_S_I, { LABEL: "stringInput" }) },
@@ -986,7 +1032,9 @@ class MathExerciseParser extends chevrotain.CstParser {
986
1032
  // Tuple value: (start, end)
987
1033
  { ALT: () => this.SUBRULE(this.tuple, { LABEL: "value" }) },
988
1034
  // Curly-brace string list: {"a", "b", "c"}
989
- { ALT: () => this.SUBRULE(this.curlyStringList, { LABEL: "value" }) }
1035
+ { ALT: () => this.SUBRULE(this.curlyStringList, { LABEL: "value" }) },
1036
+ // Square-bracket number array: [1, 2, 3] — used by VISUAL_FRACTION filled/answer
1037
+ { ALT: () => this.SUBRULE(this.numberArray, { LABEL: "value" }) }
990
1038
  ]);
991
1039
  });
992
1040
  this.tuple = this.RULE("tuple", () => {
@@ -1042,6 +1090,25 @@ class MathExerciseParser extends chevrotain.CstParser {
1042
1090
  }
1043
1091
  }
1044
1092
  const parser = new MathExerciseParser();
1093
+ function parseOrThrow(schema, data, label) {
1094
+ const result = schema.safeParse(data);
1095
+ if (!result.success) {
1096
+ const issue = result.error.issues[0];
1097
+ if (!issue) throw new Error(`${label}: validation failed`);
1098
+ const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
1099
+ throw new Error(`${label}: ${path} ${issue.message}`);
1100
+ }
1101
+ return result.data;
1102
+ }
1103
+ const VISUAL_FRACTION_SHAPES = ["bar", "circle", "triangle", "grid"];
1104
+ const VISUAL_FRACTION_SHAPE = {
1105
+ BAR: "bar",
1106
+ CIRCLE: "circle",
1107
+ TRIANGLE: "triangle",
1108
+ GRID: "grid"
1109
+ };
1110
+ const VISUAL_FRACTION_DEFAULT_SIZE = 1.35;
1111
+ const VISUAL_FRACTION_MAX_DIVISIONS = 100;
1045
1112
  const FreeTextInlineSchema = zod.z.object({
1046
1113
  type: zod.z.literal(AST_TYPES.FREE_TEXT),
1047
1114
  value: zod.z.string()
@@ -1071,6 +1138,7 @@ const InlineInputWidgetSchema = zod.z.discriminatedUnion("inputType", [
1071
1138
  name: zod.z.string(),
1072
1139
  inputType: zod.z.literal(INPUT_TYPES.STRING),
1073
1140
  length: zod.z.number().int().positive(),
1141
+ placeholder: zod.z.string().max(32).optional(),
1074
1142
  answer: zod.z.union([zod.z.string(), zod.z.number()]).optional()
1075
1143
  }),
1076
1144
  zod.z.object({
@@ -1078,6 +1146,7 @@ const InlineInputWidgetSchema = zod.z.discriminatedUnion("inputType", [
1078
1146
  name: zod.z.string(),
1079
1147
  inputType: zod.z.literal(INPUT_TYPES.NUMERIC),
1080
1148
  digits: zod.z.number().int().positive(),
1149
+ placeholder: zod.z.string().max(32).optional(),
1081
1150
  answer: zod.z.union([zod.z.string(), zod.z.number()]).optional()
1082
1151
  })
1083
1152
  ]);
@@ -1100,6 +1169,60 @@ const ImageInlineSchema = zod.z.object({
1100
1169
  width: zod.z.number().positive().optional(),
1101
1170
  height: zod.z.number().positive().optional()
1102
1171
  });
1172
+ const VisualFractionInlineSchema = zod.z.object({
1173
+ type: zod.z.literal(AST_TYPES.VISUAL_FRACTION),
1174
+ name: zod.z.string(),
1175
+ shape: zod.z.enum(VISUAL_FRACTION_SHAPES).default("bar"),
1176
+ divisions: zod.z.number().int().positive().max(VISUAL_FRACTION_MAX_DIVISIONS, {
1177
+ message: `divisions cannot exceed ${VISUAL_FRACTION_MAX_DIVISIONS}`
1178
+ }),
1179
+ filled: zod.z.array(zod.z.number().int().nonnegative()).default([]),
1180
+ answer: zod.z.array(zod.z.number().int().nonnegative()).default([]),
1181
+ orientation: zod.z.enum(["horizontal", "vertical"]).optional(),
1182
+ label: zod.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: zod.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: zod.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: zod.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: zod.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: zod.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
+ }
1103
1226
  const InlineNodeSchema = zod.z.union([
1104
1227
  FreeTextInlineSchema,
1105
1228
  SpaceInlineSchema,
@@ -1108,7 +1231,8 @@ const InlineNodeSchema = zod.z.union([
1108
1231
  BoldTextInlineSchema,
1109
1232
  InlineInputWidgetSchema,
1110
1233
  FractionInlineSchema,
1111
- ImageInlineSchema
1234
+ ImageInlineSchema,
1235
+ VisualFractionInlineSchema
1112
1236
  ]);
1113
1237
  function createFreeText(value) {
1114
1238
  return FreeTextInlineSchema.parse({
@@ -1143,23 +1267,25 @@ function createBoldText(value, highlight) {
1143
1267
  highlight
1144
1268
  });
1145
1269
  }
1146
- function createStringInput(name, length, answer) {
1147
- return InlineInputWidgetSchema.parse({
1270
+ function createStringInput(name, length, answer, placeholder) {
1271
+ return parseOrThrow(InlineInputWidgetSchema, {
1148
1272
  type: AST_TYPES.INLINE_INPUT,
1149
1273
  name,
1150
1274
  inputType: INPUT_TYPES.STRING,
1151
1275
  length,
1276
+ placeholder,
1152
1277
  answer
1153
- });
1278
+ }, "STRING_INPUT");
1154
1279
  }
1155
- function createNumericInput(name, digits, answer) {
1156
- return InlineInputWidgetSchema.parse({
1280
+ function createNumericInput(name, digits, answer, placeholder) {
1281
+ return parseOrThrow(InlineInputWidgetSchema, {
1157
1282
  type: AST_TYPES.INLINE_INPUT,
1158
1283
  name,
1159
1284
  inputType: INPUT_TYPES.NUMERIC,
1160
1285
  digits,
1286
+ placeholder,
1161
1287
  answer
1162
- });
1288
+ }, "NUMERIC_INPUT");
1163
1289
  }
1164
1290
  function createImage(name, src, alt, options = {}) {
1165
1291
  return ImageInlineSchema.parse({
@@ -1177,6 +1303,14 @@ function createFraction(name, options = {}) {
1177
1303
  ...options
1178
1304
  });
1179
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
+ }
1180
1314
  function isFreeText(value) {
1181
1315
  return FreeTextInlineSchema.safeParse(value).success;
1182
1316
  }
@@ -1195,6 +1329,9 @@ function isImage(value) {
1195
1329
  function isInlineNode(value) {
1196
1330
  return InlineNodeSchema.safeParse(value).success;
1197
1331
  }
1332
+ function isVisualFractionInline(value) {
1333
+ return VisualFractionInlineSchema.safeParse(value).success;
1334
+ }
1198
1335
  const TextBlockSchema = zod.z.object({
1199
1336
  type: zod.z.literal(AST_TYPES.TEXT_BLOCK),
1200
1337
  value: zod.z.string()
@@ -1390,6 +1527,10 @@ const DisplayFractionInlineSchema = FractionInlineSchema.refine(
1390
1527
  (f) => f.numerator !== void 0 && f.denominator !== void 0,
1391
1528
  { message: "FRACTION inside MATCHER ITEM must be in display mode (both numerator and denominator required)" }
1392
1529
  );
1530
+ const DisplayVisualFractionInlineSchema = VisualFractionInlineSchema.refine(
1531
+ (vf) => vf.answer.length === 0,
1532
+ { message: "VISUAL_FRACTION inside MATCHER ITEM must be display-only (answer must be empty: answer=[])" }
1533
+ );
1393
1534
  const DisplayInlineNodeSchema = zod.z.union([
1394
1535
  FreeTextInlineSchema,
1395
1536
  SpaceInlineSchema,
@@ -1397,7 +1538,8 @@ const DisplayInlineNodeSchema = zod.z.union([
1397
1538
  ColorTextInlineSchema,
1398
1539
  BoldTextInlineSchema,
1399
1540
  DisplayFractionInlineSchema,
1400
- ImageInlineSchema
1541
+ ImageInlineSchema,
1542
+ DisplayVisualFractionInlineSchema
1401
1543
  ]);
1402
1544
  const MatcherItemSchema = zod.z.object({
1403
1545
  type: zod.z.literal(AST_TYPES.MATCHER_ITEM),
@@ -1493,7 +1635,8 @@ const DragOptionsToolSchema = zod.z.object({
1493
1635
  const NumericKeypadToolSchema = zod.z.object({
1494
1636
  type: zod.z.literal(AST_TYPES.NUMERIC_KEYPAD),
1495
1637
  includeOperators: zod.z.boolean().optional(),
1496
- includeDecimal: zod.z.boolean().optional()
1638
+ includeDecimal: zod.z.boolean().optional(),
1639
+ style: zod.z.enum(["diamond", "classic"]).optional()
1497
1640
  });
1498
1641
  const ToolNodeSchema = zod.z.discriminatedUnion("type", [
1499
1642
  DragOptionsToolSchema,
@@ -1505,11 +1648,12 @@ function createDragOptions(options) {
1505
1648
  options
1506
1649
  });
1507
1650
  }
1508
- function createNumericKeypad(includeOperators, includeDecimal) {
1651
+ function createNumericKeypad(includeOperators, includeDecimal, style) {
1509
1652
  return NumericKeypadToolSchema.parse({
1510
1653
  type: AST_TYPES.NUMERIC_KEYPAD,
1511
1654
  includeOperators,
1512
- includeDecimal
1655
+ includeDecimal,
1656
+ style
1513
1657
  });
1514
1658
  }
1515
1659
  function isDragOptions(value) {
@@ -1863,6 +2007,9 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1863
2007
  if (ctx.image) {
1864
2008
  return this.visit(ctx.image);
1865
2009
  }
2010
+ if (ctx.visualFraction) {
2011
+ return this.visit(ctx.visualFraction);
2012
+ }
1866
2013
  if (ctx.inlineInput) {
1867
2014
  return this.visit(ctx.inlineInput);
1868
2015
  }
@@ -1914,16 +2061,32 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1914
2061
  const height = params.height;
1915
2062
  return createImage(name, src, alt, { width, height });
1916
2063
  }
2064
+ visualFraction(ctx) {
2065
+ const params = this.visit(ctx.parameterList);
2066
+ const name = params.name;
2067
+ const divisions = params.divisions;
2068
+ const shape = params.shape;
2069
+ const filled = params.filled ?? [];
2070
+ const answer = params.answer ?? [];
2071
+ const orientation = params.orientation;
2072
+ const label = params.label !== void 0 ? params.label : void 0;
2073
+ const size = params.size !== void 0 ? params.size : void 0;
2074
+ return createVisualFraction(name, divisions, { shape, filled, answer, orientation, label, size });
2075
+ }
2076
+ numberArray(ctx) {
2077
+ return ctx.items ? ctx.items.map((t) => parseFloat(t.image)) : [];
2078
+ }
1917
2079
  inlineInput(ctx) {
1918
2080
  const params = this.visit(ctx.parameterList);
1919
2081
  const name = params.name;
1920
2082
  const answer = params.answer;
2083
+ const placeholder = params.placeholder;
1921
2084
  if (ctx.stringInput) {
1922
2085
  const length = params.length;
1923
- return createStringInput(name, length, answer);
2086
+ return createStringInput(name, length, answer, placeholder);
1924
2087
  }
1925
2088
  const digits = params.digits;
1926
- return createNumericInput(name, digits, answer);
2089
+ return createNumericInput(name, digits, answer, placeholder);
1927
2090
  }
1928
2091
  // ═══════════════════════════════════════════════════════════════════════════
1929
2092
  // Tools
@@ -1945,7 +2108,8 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1945
2108
  const params = ctx.parameterList ? this.visit(ctx.parameterList) : {};
1946
2109
  const includeOperators = params.includeOperators === true || params.includeOperators === "true";
1947
2110
  const includeDecimal = params.includeDecimal === true || params.includeDecimal === "true";
1948
- return createNumericKeypad(includeOperators || void 0, includeDecimal || void 0);
2111
+ const style = params.style;
2112
+ return createNumericKeypad(includeOperators || void 0, includeDecimal || void 0, style);
1949
2113
  }
1950
2114
  // ═══════════════════════════════════════════════════════════════════════════
1951
2115
  // Validation
@@ -2188,12 +2352,14 @@ const MatcherRowItemSchema = zod.z.object({
2188
2352
  const StringInputJSONSchema = zod.z.object({
2189
2353
  type: zod.z.literal(JSON_TYPES.INLINE_INPUT),
2190
2354
  inputType: zod.z.literal(INPUT_TYPES.STRING),
2191
- length: zod.z.number().int().positive()
2355
+ length: zod.z.number().int().positive(),
2356
+ placeholder: zod.z.string().max(32).optional()
2192
2357
  });
2193
2358
  const NumericInputJSONSchema = zod.z.object({
2194
2359
  type: zod.z.literal(JSON_TYPES.INLINE_INPUT),
2195
2360
  inputType: zod.z.literal(INPUT_TYPES.NUMERIC),
2196
- digits: zod.z.number().int().positive()
2361
+ digits: zod.z.number().int().positive(),
2362
+ placeholder: zod.z.string().max(32).optional()
2197
2363
  });
2198
2364
  const InlineInputJSONSchema = zod.z.discriminatedUnion("inputType", [
2199
2365
  StringInputJSONSchema,
@@ -2210,7 +2376,8 @@ const RadioOptionsJSONSchema = zod.z.object({
2210
2376
  const NumericKeypadJSONSchema = zod.z.object({
2211
2377
  type: zod.z.literal(JSON_TYPES.NUMERIC_KEYPAD),
2212
2378
  includeOperators: zod.z.boolean().optional(),
2213
- includeDecimal: zod.z.boolean().optional()
2379
+ includeDecimal: zod.z.boolean().optional(),
2380
+ style: zod.z.enum(["diamond", "classic"]).optional()
2214
2381
  });
2215
2382
  const FractionJSONSchema = zod.z.object({
2216
2383
  type: zod.z.literal(JSON_TYPES.FRACTION),
@@ -2226,24 +2393,44 @@ const MatcherWidgetJSONSchema = zod.z.object({
2226
2393
  /** Number of answer groups — equals answers.length, stored here to avoid prop-drilling. */
2227
2394
  groupCount: zod.z.number().int().min(1)
2228
2395
  });
2396
+ const VisualFractionJSONSchema = zod.z.object({
2397
+ type: zod.z.literal(JSON_TYPES.VISUAL_FRACTION),
2398
+ shape: zod.z.enum(["bar", "circle", "triangle", "grid"]).default("bar"),
2399
+ divisions: zod.z.number().int().positive().max(VISUAL_FRACTION_MAX_DIVISIONS, {
2400
+ message: `divisions cannot exceed ${VISUAL_FRACTION_MAX_DIVISIONS}`
2401
+ }),
2402
+ filled: zod.z.array(zod.z.number().int().nonnegative()),
2403
+ answer: zod.z.array(zod.z.number().int().nonnegative()),
2404
+ orientation: zod.z.enum(["horizontal", "vertical"]).optional(),
2405
+ /** Optional label text rendered near the shape — replaces the old showLabel boolean */
2406
+ label: zod.z.string().optional(),
2407
+ /** Overall shape size in unified author units.
2408
+ * - bar / grid: overrides `--visual-fraction-part-size` CSS custom property (cell size in rem).
2409
+ * - circle / triangle: overrides `--visual-fraction-size` through the renderer scale factor.
2410
+ */
2411
+ size: zod.z.number().positive().optional()
2412
+ }).superRefine(validateVisualFractionCapacity);
2229
2413
  const WidgetJSONSchema = zod.z.union([
2230
2414
  InlineInputJSONSchema,
2231
2415
  DragOptionsJSONSchema,
2232
2416
  RadioOptionsJSONSchema,
2233
2417
  NumericKeypadJSONSchema,
2234
2418
  FractionJSONSchema,
2235
- MatcherWidgetJSONSchema
2419
+ MatcherWidgetJSONSchema,
2420
+ VisualFractionJSONSchema
2236
2421
  ]);
2237
2422
  const ValidationJSONSchema = zod.z.object({
2238
2423
  mode: zod.z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION]),
2239
2424
  // Simple answers: string|number per widget id.
2240
2425
  // Matcher answers: string[][] keyed by matcher name. Outer/inner min(1)
2241
2426
  // mirrors MatcherBlockSchema invariants in src/ast/matcher.ts.
2427
+ // VisualFraction answers: number[] — one count per color group.
2242
2428
  answers: zod.z.record(
2243
2429
  zod.z.union([
2244
2430
  zod.z.string(),
2245
2431
  zod.z.number(),
2246
- zod.z.array(zod.z.array(zod.z.string().min(1)).min(1)).min(1)
2432
+ zod.z.array(zod.z.array(zod.z.string().min(1)).min(1)).min(1),
2433
+ zod.z.array(zod.z.number().int().nonnegative())
2247
2434
  ])
2248
2435
  ).optional(),
2249
2436
  expression: zod.z.string().optional(),
@@ -2394,13 +2581,15 @@ function toJSON(ast) {
2394
2581
  widgets[inline.name] = {
2395
2582
  type: JSON_TYPES.INLINE_INPUT,
2396
2583
  inputType: INPUT_TYPES.STRING,
2397
- length: inline.length
2584
+ length: inline.length,
2585
+ placeholder: inline.placeholder
2398
2586
  };
2399
2587
  } else {
2400
2588
  widgets[inline.name] = {
2401
2589
  type: JSON_TYPES.INLINE_INPUT,
2402
2590
  inputType: INPUT_TYPES.NUMERIC,
2403
- digits: inline.digits
2591
+ digits: inline.digits,
2592
+ placeholder: inline.placeholder
2404
2593
  };
2405
2594
  }
2406
2595
  if (inline.answer !== void 0) {
@@ -2418,6 +2607,22 @@ function toJSON(ast) {
2418
2607
  answers[inline.name] = inline.answer;
2419
2608
  }
2420
2609
  }
2610
+ if (inline.type === AST_TYPES.VISUAL_FRACTION) {
2611
+ const answerArr = inline.answer ?? [];
2612
+ widgets[inline.name] = {
2613
+ type: JSON_TYPES.VISUAL_FRACTION,
2614
+ shape: inline.shape,
2615
+ divisions: inline.divisions,
2616
+ filled: inline.filled ?? [],
2617
+ answer: answerArr,
2618
+ ...inline.orientation !== void 0 && { orientation: inline.orientation },
2619
+ ...inline.label !== void 0 && { label: inline.label },
2620
+ ...inline.size !== void 0 && { size: inline.size }
2621
+ };
2622
+ if (answerArr.length > 0) {
2623
+ answers[inline.name] = answerArr;
2624
+ }
2625
+ }
2421
2626
  }
2422
2627
  for (const block of ast.lines) {
2423
2628
  if (block.type === AST_TYPES.RADIO_OPTIONS) {
@@ -2444,7 +2649,8 @@ function toJSON(ast) {
2444
2649
  widgets["_numeric_keypad"] = {
2445
2650
  type: JSON_TYPES.NUMERIC_KEYPAD,
2446
2651
  includeOperators: tool.includeOperators,
2447
- includeDecimal: tool.includeDecimal
2652
+ includeDecimal: tool.includeDecimal,
2653
+ style: tool.style
2448
2654
  };
2449
2655
  }
2450
2656
  }
@@ -2658,6 +2864,12 @@ function transformInline(inline, idGenerator) {
2658
2864
  ...inline.width !== void 0 && { width: inline.width },
2659
2865
  ...inline.height !== void 0 && { height: inline.height }
2660
2866
  };
2867
+ case AST_TYPES.VISUAL_FRACTION:
2868
+ return {
2869
+ id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
2870
+ type: JSON_TYPES.WIDGET_REF,
2871
+ widgetId: inline.name
2872
+ };
2661
2873
  default:
2662
2874
  throw new Error(`Unknown inline type: ${inline.type}`);
2663
2875
  }
@@ -8200,6 +8412,11 @@ function areMatcherEqual(expected, result) {
8200
8412
  const normalize2 = (groups) => lodashExports.sortBy(groups.map((g) => lodashExports.sortBy(g)), (g) => g.join("|"));
8201
8413
  return lodashExports.isEqual(normalize2(expected), normalize2(result));
8202
8414
  }
8415
+ function areVisualFractionCountsEqual(expected, result) {
8416
+ if (!Array.isArray(result)) return false;
8417
+ if (expected.length !== result.length) return false;
8418
+ return lodashExports.isEqual(lodashExports.sortBy(expected), lodashExports.sortBy(result));
8419
+ }
8203
8420
  const math$1 = mathjs.create(mathjs.all);
8204
8421
  function collectNumberLines(items) {
8205
8422
  const result = [];
@@ -8248,6 +8465,15 @@ function validateSimple(exercise, userInputs) {
8248
8465
  else allCorrect = false;
8249
8466
  continue;
8250
8467
  }
8468
+ if (widget?.type === JSON_TYPES.VISUAL_FRACTION) {
8469
+ const userValue2 = userInputs[widgetId];
8470
+ const isCorrect2 = areVisualFractionCountsEqual(expected, userValue2);
8471
+ details[widgetId] = { widgetId, userValue: userValue2, isCorrect: isCorrect2 };
8472
+ totalCount++;
8473
+ if (isCorrect2) correctCount++;
8474
+ else allCorrect = false;
8475
+ continue;
8476
+ }
8251
8477
  totalCount++;
8252
8478
  const userValue = userInputs[widgetId];
8253
8479
  const isFractionLenient = widget?.type === JSON_TYPES.FRACTION && widget.simplified === false;
@@ -8754,10 +8980,10 @@ function getOwnerDocument(target) {
8754
8980
  }
8755
8981
  return document;
8756
8982
  }
8757
- const useIsomorphicLayoutEffect = canUseDOM ? React.useLayoutEffect : React.useEffect;
8983
+ const useIsomorphicLayoutEffect$1 = canUseDOM ? React.useLayoutEffect : React.useEffect;
8758
8984
  function useEvent(handler) {
8759
8985
  const handlerRef = React.useRef(handler);
8760
- useIsomorphicLayoutEffect(() => {
8986
+ useIsomorphicLayoutEffect$1(() => {
8761
8987
  handlerRef.current = handler;
8762
8988
  });
8763
8989
  return React.useCallback(function() {
@@ -8785,7 +9011,7 @@ function useLatestValue(value, dependencies) {
8785
9011
  dependencies = [value];
8786
9012
  }
8787
9013
  const valueRef = React.useRef(value);
8788
- useIsomorphicLayoutEffect(() => {
9014
+ useIsomorphicLayoutEffect$1(() => {
8789
9015
  if (valueRef.current !== value) {
8790
9016
  valueRef.current = value;
8791
9017
  }
@@ -10653,9 +10879,9 @@ function useResizeObserver(_ref) {
10653
10879
  return void 0;
10654
10880
  }
10655
10881
  const {
10656
- ResizeObserver
10882
+ ResizeObserver: ResizeObserver2
10657
10883
  } = window;
10658
- return new ResizeObserver(handleResize);
10884
+ return new ResizeObserver2(handleResize);
10659
10885
  },
10660
10886
  // eslint-disable-next-line react-hooks/exhaustive-deps
10661
10887
  [disabled]
@@ -10709,7 +10935,7 @@ function useRect(element, measure, fallbackRect) {
10709
10935
  const resizeObserver = useResizeObserver({
10710
10936
  callback: measureRect
10711
10937
  });
10712
- useIsomorphicLayoutEffect(() => {
10938
+ useIsomorphicLayoutEffect$1(() => {
10713
10939
  measureRect();
10714
10940
  if (element) {
10715
10941
  resizeObserver == null ? void 0 : resizeObserver.observe(element);
@@ -10883,7 +11109,7 @@ function useRects(elements, measure) {
10883
11109
  const resizeObserver = useResizeObserver({
10884
11110
  callback: measureRects
10885
11111
  });
10886
- useIsomorphicLayoutEffect(() => {
11112
+ useIsomorphicLayoutEffect$1(() => {
10887
11113
  resizeObserver == null ? void 0 : resizeObserver.disconnect();
10888
11114
  measureRects();
10889
11115
  elements.forEach((element) => resizeObserver == null ? void 0 : resizeObserver.observe(element));
@@ -11245,7 +11471,7 @@ function useLayoutShiftScrollCompensation(_ref) {
11245
11471
  x: config,
11246
11472
  y: config
11247
11473
  } : config;
11248
- useIsomorphicLayoutEffect(() => {
11474
+ useIsomorphicLayoutEffect$1(() => {
11249
11475
  const disabled = !x && !y;
11250
11476
  if (disabled || !activeNode) {
11251
11477
  initialized.current = false;
@@ -11606,7 +11832,7 @@ const DndContext = /* @__PURE__ */ React.memo(function DndContext2(_ref) {
11606
11832
  }, [draggableNodes, instantiateSensor]);
11607
11833
  const activators = useCombineActivators(sensors, bindActivatorToSensorInstantiator);
11608
11834
  useSensorSetup(sensors);
11609
- useIsomorphicLayoutEffect(() => {
11835
+ useIsomorphicLayoutEffect$1(() => {
11610
11836
  if (activeNodeRect && status === Status.Initializing) {
11611
11837
  setStatus(Status.Initialized);
11612
11838
  }
@@ -11690,7 +11916,7 @@ const DndContext = /* @__PURE__ */ React.memo(function DndContext2(_ref) {
11690
11916
  // eslint-disable-next-line react-hooks/exhaustive-deps
11691
11917
  [overId]
11692
11918
  );
11693
- useIsomorphicLayoutEffect(() => {
11919
+ useIsomorphicLayoutEffect$1(() => {
11694
11920
  sensorContext.current = {
11695
11921
  activatorEvent,
11696
11922
  active,
@@ -11817,7 +12043,7 @@ function useDraggable(_ref) {
11817
12043
  const [activatorNode, setActivatorNodeRef] = useNodeRef();
11818
12044
  const listeners = useSyntheticListeners(activators, id);
11819
12045
  const dataRef = useLatestValue(data);
11820
- useIsomorphicLayoutEffect(
12046
+ useIsomorphicLayoutEffect$1(
11821
12047
  () => {
11822
12048
  draggableNodes.set(id, {
11823
12049
  id,
@@ -11990,7 +12216,7 @@ function AnimationManager(_ref) {
11990
12216
  if (!children && !clonedChildren && previousChildren) {
11991
12217
  setClonedChildren(previousChildren);
11992
12218
  }
11993
- useIsomorphicLayoutEffect(() => {
12219
+ useIsomorphicLayoutEffect$1(() => {
11994
12220
  if (!element) {
11995
12221
  return;
11996
12222
  }
@@ -12672,6 +12898,7 @@ const InlineInput = ({
12672
12898
  inputType,
12673
12899
  length,
12674
12900
  digits,
12901
+ placeholder,
12675
12902
  onChange,
12676
12903
  onFocus
12677
12904
  }) => {
@@ -12717,19 +12944,88 @@ const InlineInput = ({
12717
12944
  onChange: (e) => handleChange(e.target.value, false),
12718
12945
  onFocus: () => onFocus?.(widgetId),
12719
12946
  maxLength: expectedLength,
12720
- style: { width: dynamicWidth }
12947
+ style: { width: dynamicWidth },
12948
+ placeholder
12721
12949
  }
12722
12950
  )
12723
12951
  )
12724
12952
  }
12725
12953
  );
12726
12954
  };
12955
+ const PillBackground = ({
12956
+ width = "100%",
12957
+ minHeight = "72px",
12958
+ fill = "#FFE7B4",
12959
+ children,
12960
+ className = "",
12961
+ style,
12962
+ ...rest
12963
+ }) => {
12964
+ return /* @__PURE__ */ jsxRuntime.jsxs(
12965
+ "div",
12966
+ {
12967
+ className: `pill-bg-wrapper ${className}`.trim(),
12968
+ style: {
12969
+ position: "relative",
12970
+ display: "inline-flex",
12971
+ alignItems: "center",
12972
+ justifyContent: "center",
12973
+ width,
12974
+ minHeight,
12975
+ ...style
12976
+ },
12977
+ ...rest,
12978
+ children: [
12979
+ /* @__PURE__ */ jsxRuntime.jsx(
12980
+ "svg",
12981
+ {
12982
+ viewBox: "0 0 510 108",
12983
+ preserveAspectRatio: "none",
12984
+ xmlns: "http://www.w3.org/2000/svg",
12985
+ "aria-hidden": "true",
12986
+ style: {
12987
+ position: "absolute",
12988
+ inset: 0,
12989
+ width: "100%",
12990
+ height: "100%",
12991
+ display: "block"
12992
+ },
12993
+ children: /* @__PURE__ */ jsxRuntime.jsx(
12994
+ "path",
12995
+ {
12996
+ 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",
12997
+ fill
12998
+ }
12999
+ )
13000
+ }
13001
+ ),
13002
+ /* @__PURE__ */ jsxRuntime.jsx(
13003
+ "div",
13004
+ {
13005
+ style: {
13006
+ position: "relative",
13007
+ zIndex: 1,
13008
+ display: "flex",
13009
+ alignItems: "center",
13010
+ justifyContent: "center",
13011
+ width: "100%",
13012
+ height: "100%",
13013
+ padding: "0 2rem",
13014
+ boxSizing: "border-box"
13015
+ },
13016
+ children
13017
+ }
13018
+ )
13019
+ ]
13020
+ }
13021
+ );
13022
+ };
12727
13023
  const DragOptions = ({
12728
13024
  options,
12729
13025
  onOptionClick
12730
13026
  }) => {
12731
13027
  const { usedOptionIds } = useExerciseDnd();
12732
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-options", children: options.map((option, idx) => {
13028
+ return /* @__PURE__ */ jsxRuntime.jsx(PillBackground, { children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-options", children: options.map((option, idx) => {
12733
13029
  const optionId = `drag-option-${idx}`;
12734
13030
  const optionValue = String(option);
12735
13031
  const isUsed = usedOptionIds.has(optionId);
@@ -12756,7 +13052,7 @@ const DragOptions = ({
12756
13052
  }
12757
13053
  )
12758
13054
  ) }, optionId);
12759
- }) });
13055
+ }) }) });
12760
13056
  };
12761
13057
  const NumericKeypad = ({
12762
13058
  value,
@@ -12852,28 +13148,6 @@ const FractionInput = ({
12852
13148
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-denominator", children: renderSlot(denKey, denominator, denValue, "denominator") })
12853
13149
  ] });
12854
13150
  };
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
13151
  function isLightColor(hex) {
12878
13152
  const clean = hex.replace("#", "");
12879
13153
  const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
@@ -12888,7 +13162,8 @@ function isLightColor(hex) {
12888
13162
  return 1.05 / (L + 0.05) < 4.5;
12889
13163
  }
12890
13164
  function getTextColorForBackground(color) {
12891
- if (color.startsWith("hsl(")) return "#1a1a1a";
13165
+ const hslLightness = parseHslLightness(color);
13166
+ if (hslLightness !== null) return hslLightness >= 60 ? "#1a1a1a" : "#ffffff";
12892
13167
  return isLightColor(color) ? "#1a1a1a" : "#ffffff";
12893
13168
  }
12894
13169
  function generateHslPalette(n) {
@@ -12897,7 +13172,7 @@ function generateHslPalette(n) {
12897
13172
  return `hsl(${hue}, 60%, 72%)`;
12898
13173
  });
12899
13174
  }
12900
- function resolveMatcherPalette(userColors, groupCount) {
13175
+ function resolveGroupPalette(userColors, groupCount) {
12901
13176
  if (groupCount <= 0) return [];
12902
13177
  if (!userColors || userColors.length === 0) {
12903
13178
  return generateHslPalette(groupCount);
@@ -12909,6 +13184,478 @@ function resolveMatcherPalette(userColors, groupCount) {
12909
13184
  const extras = generateHslPalette(needed);
12910
13185
  return [...userColors, ...extras];
12911
13186
  }
13187
+ function resolveDistinctGroupPalette(groupCount, options = {}) {
13188
+ if (groupCount <= 0) return [];
13189
+ const used = new Set((options.excludeColors ?? []).map(normalizeColorToken));
13190
+ const palette = [];
13191
+ for (const color of options.seedColors ?? []) {
13192
+ const token = normalizeColorToken(color);
13193
+ if (used.has(token)) continue;
13194
+ palette.push(color);
13195
+ used.add(token);
13196
+ if (palette.length === groupCount) return palette;
13197
+ }
13198
+ const addCandidates = (candidates) => {
13199
+ for (const color of candidates) {
13200
+ const token = normalizeColorToken(color);
13201
+ if (used.has(token)) continue;
13202
+ palette.push(color);
13203
+ used.add(token);
13204
+ if (palette.length === groupCount) return;
13205
+ }
13206
+ };
13207
+ addCandidates(generateHslPalette(groupCount));
13208
+ let candidateCount = groupCount + 1;
13209
+ while (palette.length < groupCount) {
13210
+ addCandidates(generateHslPalette(candidateCount++));
13211
+ }
13212
+ return palette;
13213
+ }
13214
+ function getGroupClassName(groupIndex) {
13215
+ return `widget-group-${groupIndex + 1}`;
13216
+ }
13217
+ function normalizeColorToken(color) {
13218
+ return color.replace(/\s+/g, "").toLowerCase();
13219
+ }
13220
+ function parseHslLightness(color) {
13221
+ const match = color.match(/^hsl\(\s*[\d.]+\s*,\s*[\d.]+%\s*,\s*([\d.]+)%\s*\)$/i);
13222
+ return match ? Number(match[1]) : null;
13223
+ }
13224
+ function sumCounts(counts) {
13225
+ return counts.reduce((sum, count) => sum + count, 0);
13226
+ }
13227
+ function countsToPartGroups(counts) {
13228
+ return counts.flatMap(
13229
+ (count, groupIndex) => Array.from({ length: count }, () => groupIndex)
13230
+ );
13231
+ }
13232
+ function normalizeCounts(value, expected) {
13233
+ if (!Array.isArray(value)) return expected.map(() => 0);
13234
+ return expected.map((max, i) => Math.max(0, Math.min(max, value[i] ?? 0)));
13235
+ }
13236
+ function serializeCounts(value) {
13237
+ return Array.isArray(value) ? value.join("|") : null;
13238
+ }
13239
+ function countsToAssignments(counts, divisions, filledCount) {
13240
+ const assignments = Array.from({ length: divisions }, () => null);
13241
+ let index = filledCount;
13242
+ counts.forEach((count, groupIndex) => {
13243
+ for (let i = 0; i < count && index < divisions; i++) {
13244
+ assignments[index] = groupIndex;
13245
+ index++;
13246
+ }
13247
+ });
13248
+ return assignments;
13249
+ }
13250
+ function getNextAssignments(assignments, partIndex, groupCount) {
13251
+ if (groupCount === 0) return [...assignments];
13252
+ const current = assignments[partIndex] ?? null;
13253
+ const next = current === null ? 0 : current + 1 >= groupCount ? null : current + 1;
13254
+ const updated = [...assignments];
13255
+ updated[partIndex] = next;
13256
+ return updated;
13257
+ }
13258
+ function assignmentsToCounts(assignments, groupCount) {
13259
+ const counts = Array.from({ length: groupCount }, () => 0);
13260
+ for (const groupIndex of assignments) {
13261
+ if (groupIndex !== null && groupIndex >= 0 && groupIndex < groupCount) {
13262
+ counts[groupIndex] = (counts[groupIndex] ?? 0) + 1;
13263
+ }
13264
+ }
13265
+ return counts;
13266
+ }
13267
+ function useVisualFractionState({
13268
+ widgetId,
13269
+ divisions,
13270
+ filledCount,
13271
+ answer,
13272
+ value,
13273
+ onChange
13274
+ }) {
13275
+ const [partAssignments, setPartAssignments] = React.useState(
13276
+ () => countsToAssignments(normalizeCounts(value, answer), divisions, filledCount)
13277
+ );
13278
+ const lastInternalValueRef = React.useRef(null);
13279
+ const valueKey = serializeCounts(value);
13280
+ React.useEffect(() => {
13281
+ if (valueKey !== null && valueKey === lastInternalValueRef.current) return;
13282
+ lastInternalValueRef.current = null;
13283
+ setPartAssignments(
13284
+ countsToAssignments(normalizeCounts(value, answer), divisions, filledCount)
13285
+ );
13286
+ }, [answer, divisions, filledCount, valueKey]);
13287
+ const handlePartClick = (partIndex) => {
13288
+ if (partIndex < filledCount) return;
13289
+ const updated = getNextAssignments(partAssignments, partIndex, answer.length);
13290
+ const nextCounts = assignmentsToCounts(updated, answer.length);
13291
+ lastInternalValueRef.current = serializeCounts(nextCounts);
13292
+ setPartAssignments(updated);
13293
+ onChange(widgetId, nextCounts);
13294
+ };
13295
+ return { partAssignments, handlePartClick };
13296
+ }
13297
+ const VISUAL_FRACTION_DISPLAY_COLOR = "#AE9CFF";
13298
+ const VISUAL_FRACTION_BORDER_COLOR = "#705DC0";
13299
+ const VISUAL_FRACTION_SVG_SIZE_SCALE = 8;
13300
+ function getCircleSectorProps(index, divisions) {
13301
+ if (divisions <= 1) return { kind: "circle" };
13302
+ const angle = 360 / divisions;
13303
+ const startAngle = -90 + index * angle;
13304
+ const endAngle = startAngle + angle;
13305
+ const start = polarToCartesian(50, 50, 48, endAngle);
13306
+ const end = polarToCartesian(50, 50, 48, startAngle);
13307
+ const largeArcFlag = angle > 180 ? 1 : 0;
13308
+ return {
13309
+ kind: "path",
13310
+ d: [
13311
+ "M",
13312
+ 50,
13313
+ 50,
13314
+ "L",
13315
+ start.x,
13316
+ start.y,
13317
+ "A",
13318
+ 48,
13319
+ 48,
13320
+ 0,
13321
+ largeArcFlag,
13322
+ 0,
13323
+ end.x,
13324
+ end.y,
13325
+ "Z"
13326
+ ].join(" ")
13327
+ };
13328
+ }
13329
+ function polarToCartesian(cx, cy, r, angleInDegrees) {
13330
+ const rad = angleInDegrees * Math.PI / 180;
13331
+ return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
13332
+ }
13333
+ function getEqualTrianglePartPath(index, divisions) {
13334
+ const side = Math.sqrt(divisions);
13335
+ if (!Number.isInteger(side)) {
13336
+ throw new Error(`getEqualTrianglePartPath: divisions (${divisions}) must be a perfect square.`);
13337
+ }
13338
+ const smallSide = 96 / side;
13339
+ const smallHeight = smallSide * Math.sqrt(3) / 2;
13340
+ const totalHeight = side * smallHeight;
13341
+ const offsetY = (100 - totalHeight) / 2;
13342
+ let cursor = 0;
13343
+ for (let row = 0; row < side; row++) {
13344
+ const y = offsetY + row * smallHeight;
13345
+ const rowLeft = 50 - (row + 1) * smallSide / 2;
13346
+ for (let col = 0; col <= row; col++) {
13347
+ if (cursor === index) {
13348
+ const x = rowLeft + col * smallSide;
13349
+ return ["M", x + smallSide / 2, y, "L", x + smallSide, y + smallHeight, "L", x, y + smallHeight, "Z"].join(" ");
13350
+ }
13351
+ cursor++;
13352
+ }
13353
+ for (let col = 0; col < row; col++) {
13354
+ if (cursor === index) {
13355
+ const x = rowLeft + col * smallSide;
13356
+ return ["M", x + smallSide, y + smallHeight, "L", x + smallSide / 2, y, "L", x + smallSide * 1.5, y, "Z"].join(" ");
13357
+ }
13358
+ cursor++;
13359
+ }
13360
+ }
13361
+ return "";
13362
+ }
13363
+ function getGridColumnCount(divisions) {
13364
+ return Math.ceil(Math.sqrt(divisions));
13365
+ }
13366
+ function getInteractiveProps(part, onPartClick) {
13367
+ if (!part.isInteractiveSlot) return {};
13368
+ return {
13369
+ onClick: () => onPartClick(part.index),
13370
+ role: "button",
13371
+ "aria-pressed": part.isSelected,
13372
+ "aria-label": getPartAriaLabel(part),
13373
+ tabIndex: 0,
13374
+ onKeyDown: (event) => {
13375
+ if (event.key === "Enter" || event.key === " ") {
13376
+ event.preventDefault();
13377
+ onPartClick(part.index);
13378
+ }
13379
+ }
13380
+ };
13381
+ }
13382
+ function getPartAriaLabel(part) {
13383
+ return part.groupIndex === null ? `Part ${part.index + 1}, empty` : `Part ${part.index + 1}, group ${part.groupIndex + 1}`;
13384
+ }
13385
+ const BarShape = ({
13386
+ parts,
13387
+ divisions,
13388
+ orientation,
13389
+ onPartClick
13390
+ }) => {
13391
+ const renderedParts = orientation === "vertical" ? [...parts].reverse() : parts;
13392
+ return /* @__PURE__ */ jsxRuntime.jsx(
13393
+ "div",
13394
+ {
13395
+ className: `visual-fraction-widget__parts${orientation === "vertical" ? " visual-fraction-widget__parts--vertical" : " visual-fraction-widget__parts--horizontal"}`,
13396
+ style: { "--visual-fraction-divisions": divisions },
13397
+ children: renderedParts.map((part) => /* @__PURE__ */ jsxRuntime.jsx(
13398
+ "div",
13399
+ {
13400
+ className: part.className,
13401
+ style: part.style,
13402
+ ...getInteractiveProps(part, onPartClick)
13403
+ },
13404
+ part.index
13405
+ ))
13406
+ }
13407
+ );
13408
+ };
13409
+ BarShape.displayName = "BarShape";
13410
+ const GridShape = ({
13411
+ parts,
13412
+ divisions,
13413
+ onPartClick
13414
+ }) => /* @__PURE__ */ jsxRuntime.jsx(
13415
+ "div",
13416
+ {
13417
+ className: "visual-fraction-widget__parts visual-fraction-widget__parts--horizontal",
13418
+ style: {
13419
+ "--visual-fraction-divisions": divisions,
13420
+ "--visual-fraction-grid-cols": getGridColumnCount(divisions)
13421
+ },
13422
+ children: parts.map((part) => /* @__PURE__ */ jsxRuntime.jsx(
13423
+ "div",
13424
+ {
13425
+ className: part.className,
13426
+ style: part.style,
13427
+ ...getInteractiveProps(part, onPartClick)
13428
+ },
13429
+ part.index
13430
+ ))
13431
+ }
13432
+ );
13433
+ GridShape.displayName = "GridShape";
13434
+ const CircleShape = ({
13435
+ parts,
13436
+ divisions,
13437
+ onPartClick
13438
+ }) => /* @__PURE__ */ jsxRuntime.jsx(
13439
+ "svg",
13440
+ {
13441
+ className: "visual-fraction-widget__shape visual-fraction-widget__circle",
13442
+ viewBox: "0 0 100 100",
13443
+ children: parts.map((part) => {
13444
+ const sectorProps = getCircleSectorProps(part.index, divisions);
13445
+ const className = `${part.className} visual-fraction-widget__sector`;
13446
+ const interactiveProps = getInteractiveProps(part, onPartClick);
13447
+ return sectorProps.kind === "circle" ? /* @__PURE__ */ jsxRuntime.jsx(
13448
+ "circle",
13449
+ {
13450
+ className,
13451
+ style: part.style,
13452
+ cx: "50",
13453
+ cy: "50",
13454
+ r: "48",
13455
+ ...interactiveProps
13456
+ },
13457
+ part.index
13458
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
13459
+ "path",
13460
+ {
13461
+ className,
13462
+ style: part.style,
13463
+ d: sectorProps.d,
13464
+ ...interactiveProps
13465
+ },
13466
+ part.index
13467
+ );
13468
+ })
13469
+ }
13470
+ );
13471
+ CircleShape.displayName = "CircleShape";
13472
+ const TriangleShape = ({
13473
+ parts,
13474
+ divisions,
13475
+ onPartClick
13476
+ }) => /* @__PURE__ */ jsxRuntime.jsx(
13477
+ "svg",
13478
+ {
13479
+ className: "visual-fraction-widget__shape visual-fraction-widget__triangle",
13480
+ viewBox: "0 0 100 100",
13481
+ children: parts.map((part) => /* @__PURE__ */ jsxRuntime.jsx(
13482
+ "path",
13483
+ {
13484
+ className: `${part.className} visual-fraction-widget__sector`,
13485
+ style: part.style,
13486
+ d: getEqualTrianglePartPath(part.index, divisions),
13487
+ ...getInteractiveProps(part, onPartClick)
13488
+ },
13489
+ part.index
13490
+ ))
13491
+ }
13492
+ );
13493
+ TriangleShape.displayName = "TriangleShape";
13494
+ const ShapeBar = ({ parts, divisions, orientation, onPartClick }) => /* @__PURE__ */ jsxRuntime.jsx(BarShape, { parts, divisions, orientation, onPartClick });
13495
+ const ShapeCircle = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsxRuntime.jsx(CircleShape, { parts, divisions, onPartClick });
13496
+ const ShapeTriangle = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsxRuntime.jsx(TriangleShape, { parts, divisions, onPartClick });
13497
+ const ShapeGrid = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsxRuntime.jsx(GridShape, { parts, divisions, onPartClick });
13498
+ const SHAPE_COMPONENTS = {
13499
+ [VISUAL_FRACTION_SHAPE.BAR]: ShapeBar,
13500
+ [VISUAL_FRACTION_SHAPE.CIRCLE]: ShapeCircle,
13501
+ [VISUAL_FRACTION_SHAPE.TRIANGLE]: ShapeTriangle,
13502
+ [VISUAL_FRACTION_SHAPE.GRID]: ShapeGrid
13503
+ };
13504
+ const VisualFraction = ({
13505
+ widgetId,
13506
+ widget,
13507
+ value,
13508
+ validationStatus = null,
13509
+ onChange
13510
+ }) => {
13511
+ const { shape, divisions, filled, answer, orientation, label } = widget;
13512
+ const filledCount = sumCounts(filled);
13513
+ const filledPartGroups = React.useMemo(() => countsToPartGroups(filled), [filled]);
13514
+ const filledPalette = React.useMemo(() => {
13515
+ if (filled.length === 0) return [];
13516
+ return resolveDistinctGroupPalette(filled.length, {
13517
+ seedColors: [VISUAL_FRACTION_DISPLAY_COLOR],
13518
+ excludeColors: [VISUAL_FRACTION_BORDER_COLOR]
13519
+ });
13520
+ }, [filled]);
13521
+ const answerPalette = React.useMemo(() => {
13522
+ if (answer.length === 0) return [];
13523
+ return resolveDistinctGroupPalette(answer.length, {
13524
+ excludeColors: [VISUAL_FRACTION_BORDER_COLOR, ...filledPalette]
13525
+ });
13526
+ }, [answer, filledPalette]);
13527
+ const { partAssignments, handlePartClick } = useVisualFractionState({
13528
+ widgetId,
13529
+ divisions,
13530
+ filledCount,
13531
+ answer,
13532
+ value,
13533
+ onChange
13534
+ });
13535
+ const parts = React.useMemo(
13536
+ () => Array.from({ length: divisions }, (_, i) => {
13537
+ let partClass = "visual-fraction-widget__part visual-fraction-widget__part--empty";
13538
+ let groupIdx = null;
13539
+ if (i < filledCount) {
13540
+ groupIdx = filledPartGroups[i] ?? 0;
13541
+ partClass = "visual-fraction-widget__part visual-fraction-widget__part--filled";
13542
+ } else if (partAssignments[i] != null) {
13543
+ groupIdx = partAssignments[i];
13544
+ partClass = "visual-fraction-widget__part visual-fraction-widget__part--answer";
13545
+ }
13546
+ if (groupIdx !== null) {
13547
+ partClass += ` visual-fraction-widget__part--group-${groupIdx + 1} ${getGroupClassName(groupIdx)}`;
13548
+ }
13549
+ const colorKind = i < filledCount ? "filled" : "answer";
13550
+ const palette = colorKind === "filled" ? filledPalette : answerPalette;
13551
+ const fallbackColor = palette[groupIdx ?? 0];
13552
+ const style = groupIdx !== null ? {
13553
+ "--visual-fraction-part-color": `var(--visual-fraction-${colorKind}-group-${groupIdx + 1}-color, ${fallbackColor})`,
13554
+ "--visual-fraction-part-text-color": `var(--visual-fraction-${colorKind}-group-${groupIdx + 1}-text-color, ${getTextColorForBackground(fallbackColor)})`
13555
+ } : void 0;
13556
+ return {
13557
+ index: i,
13558
+ className: partClass,
13559
+ style,
13560
+ // A slot is interactive when there are answer groups AND it falls in the non-filled range.
13561
+ // answer.length counts groups (not total slots); the state hook handles per-slot cycling.
13562
+ isInteractiveSlot: answer.length > 0 && i >= filledCount,
13563
+ isSelected: groupIdx !== null,
13564
+ groupIndex: groupIdx
13565
+ };
13566
+ }),
13567
+ [divisions, filledCount, filledPartGroups, partAssignments, answer, filledPalette, answerPalette]
13568
+ );
13569
+ const statusClass = validationStatus ? ` visual-fraction-widget--${validationStatus} widget--${validationStatus}` : "";
13570
+ const rootStyle = React.useMemo(() => getSizeStyle(widget.size, shape), [widget.size, shape]);
13571
+ const ShapeComponent = SHAPE_COMPONENTS[shape];
13572
+ return /* @__PURE__ */ jsxRuntime.jsxs(
13573
+ "div",
13574
+ {
13575
+ className: `visual-fraction-widget visual-fraction-widget--${shape}${statusClass}`,
13576
+ style: rootStyle,
13577
+ role: "group",
13578
+ "aria-label": `${divisions} part ${shape} fraction`,
13579
+ children: [
13580
+ /* @__PURE__ */ jsxRuntime.jsx(
13581
+ ShapeComponent,
13582
+ {
13583
+ parts,
13584
+ divisions,
13585
+ orientation: orientation ?? "horizontal",
13586
+ onPartClick: handlePartClick
13587
+ }
13588
+ ),
13589
+ label && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "visual-fraction-widget__label", children: label })
13590
+ ]
13591
+ }
13592
+ );
13593
+ };
13594
+ VisualFraction.displayName = "VisualFraction";
13595
+ function getSizeStyle(size, shape) {
13596
+ if (size === void 0) return void 0;
13597
+ if (shape === "circle" || shape === "triangle") {
13598
+ return {
13599
+ "--visual-fraction-size": `${size * VISUAL_FRACTION_SVG_SIZE_SCALE}rem`
13600
+ };
13601
+ }
13602
+ return {
13603
+ "--visual-fraction-part-size": `${size}rem`
13604
+ };
13605
+ }
13606
+ const RadioOptions = ({
13607
+ name,
13608
+ options,
13609
+ value,
13610
+ validationStatus = null,
13611
+ onChange
13612
+ }) => {
13613
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "radio-options", children: options.map((option, idx) => {
13614
+ const isSelected = value === option;
13615
+ const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
13616
+ return /* @__PURE__ */ jsxRuntime.jsx(
13617
+ "button",
13618
+ {
13619
+ className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
13620
+ onClick: () => onChange(name, option),
13621
+ type: "button",
13622
+ children: option
13623
+ },
13624
+ `${name}-radio-${idx}`
13625
+ );
13626
+ }) });
13627
+ };
13628
+ const Btn = ({
13629
+ label,
13630
+ extraClass = "keypad-btn-digit",
13631
+ onClick
13632
+ }) => /* @__PURE__ */ jsxRuntime.jsx("button", { className: `keypad-btn ${extraClass}`, onClick, children: /* @__PURE__ */ jsxRuntime.jsx("span", { children: label }) });
13633
+ const DiamondKeypad = ({ value, onChange }) => {
13634
+ const append = (ch) => onChange(value + ch);
13635
+ const backspace = () => onChange(value.slice(0, -1));
13636
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "diamond-keypad", children: [
13637
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "diamond-keypad-row", children: [
13638
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "1", onClick: () => append("1") }),
13639
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "2", onClick: () => append("2") }),
13640
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "3", onClick: () => append("3") })
13641
+ ] }),
13642
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "diamond-keypad-row", children: [
13643
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "4", onClick: () => append("4") }),
13644
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "5", onClick: () => append("5") }),
13645
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "6", onClick: () => append("6") }),
13646
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "7", onClick: () => append("7") })
13647
+ ] }),
13648
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "diamond-keypad-row", children: [
13649
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "8", onClick: () => append("8") }),
13650
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "9", onClick: () => append("9") }),
13651
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "0", onClick: () => append("0") })
13652
+ ] }),
13653
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "diamond-keypad-row", children: [
13654
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: ".", extraClass: "keypad-btn-decimal", onClick: () => append(".") }),
13655
+ /* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "⌫", extraClass: "keypad-btn-backspace", onClick: backspace })
13656
+ ] })
13657
+ ] });
13658
+ };
12912
13659
  function collectItemIds(rows) {
12913
13660
  return rows.flatMap((row) => row.items.map((item) => item.itemId));
12914
13661
  }
@@ -12950,7 +13697,7 @@ const MatcherWidget = ({
12950
13697
  const { rows, colors: userColors, groupCount } = widget;
12951
13698
  const widgetsMap = widgets ?? EMPTY_WIDGETS;
12952
13699
  const palette = React.useMemo(
12953
- () => resolveMatcherPalette(userColors, groupCount),
13700
+ () => resolveGroupPalette(userColors, groupCount),
12954
13701
  [userColors, groupCount]
12955
13702
  );
12956
13703
  const textColors = React.useMemo(
@@ -12966,13 +13713,21 @@ const MatcherWidget = ({
12966
13713
  setAssignments((prev) => {
12967
13714
  const current = prev[itemId] ?? null;
12968
13715
  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;
13716
+ return { ...prev, [itemId]: next };
12972
13717
  });
12973
13718
  },
12974
- [groupCount, onChange, widgetId]
13719
+ [groupCount]
12975
13720
  );
13721
+ const onChangeRef = React.useRef(onChange);
13722
+ onChangeRef.current = onChange;
13723
+ const isMounted = React.useRef(false);
13724
+ React.useEffect(() => {
13725
+ if (!isMounted.current) {
13726
+ isMounted.current = true;
13727
+ return;
13728
+ }
13729
+ onChangeRef.current(widgetId, buildGroups(assignments));
13730
+ }, [assignments, widgetId]);
12976
13731
  const maxCols = React.useMemo(
12977
13732
  () => rows.length === 0 ? 1 : Math.max(...rows.map((r) => r.items.length)),
12978
13733
  [rows]
@@ -12993,9 +13748,9 @@ const MatcherWidget = ({
12993
13748
  {
12994
13749
  type: "button",
12995
13750
  className: `matcher-card${isSelected ? " matcher-card--selected" : ""}`,
12996
- style: color ? {
12997
- "--matcher-card-color": color,
12998
- "--matcher-card-text-color": textColors[colorIdx]
13751
+ style: colorIdx !== null && color ? {
13752
+ "--matcher-card-color": `var(--matcher-group-${colorIdx + 1}-color, ${color})`,
13753
+ "--matcher-card-text-color": `var(--matcher-group-${colorIdx + 1}-text-color, ${textColors[colorIdx]})`
12999
13754
  } : void 0,
13000
13755
  onClick: () => handleCardClick(item.itemId),
13001
13756
  "aria-pressed": isSelected,
@@ -13037,6 +13792,9 @@ function isFractionWidget(widget) {
13037
13792
  function isMatcherWidget(widget) {
13038
13793
  return widget.type === "matcher";
13039
13794
  }
13795
+ function isVisualFractionWidget(widget) {
13796
+ return widget.type === "visual-fraction";
13797
+ }
13040
13798
  const WidgetRenderer = ({
13041
13799
  widget,
13042
13800
  widgetId,
@@ -13062,6 +13820,7 @@ const WidgetRenderer = ({
13062
13820
  inputType: widget.inputType,
13063
13821
  length: widget.inputType === "string" ? widget.length : void 0,
13064
13822
  digits: widget.inputType === "numeric" ? widget.digits : void 0,
13823
+ placeholder: widget.placeholder,
13065
13824
  onChange,
13066
13825
  onFocus: onInputFocus
13067
13826
  }
@@ -13090,14 +13849,24 @@ const WidgetRenderer = ({
13090
13849
  );
13091
13850
  }
13092
13851
  if (isNumericKeypadWidget(widget)) {
13852
+ const keypadOnChange = onKeypadChange || (() => {
13853
+ });
13854
+ if (widget.style === "classic") {
13855
+ return /* @__PURE__ */ jsxRuntime.jsx(
13856
+ NumericKeypad,
13857
+ {
13858
+ value: strValue,
13859
+ includeOperators: widget.includeOperators,
13860
+ includeDecimal: widget.includeDecimal,
13861
+ onChange: keypadOnChange
13862
+ }
13863
+ );
13864
+ }
13093
13865
  return /* @__PURE__ */ jsxRuntime.jsx(
13094
- NumericKeypad,
13866
+ DiamondKeypad,
13095
13867
  {
13096
13868
  value: strValue,
13097
- includeOperators: widget.includeOperators,
13098
- includeDecimal: widget.includeDecimal,
13099
- onChange: onKeypadChange || (() => {
13100
- })
13869
+ onChange: keypadOnChange
13101
13870
  }
13102
13871
  );
13103
13872
  }
@@ -13129,6 +13898,18 @@ const WidgetRenderer = ({
13129
13898
  }
13130
13899
  );
13131
13900
  }
13901
+ if (isVisualFractionWidget(widget)) {
13902
+ return /* @__PURE__ */ jsxRuntime.jsx(
13903
+ VisualFraction,
13904
+ {
13905
+ widgetId,
13906
+ widget,
13907
+ value: Array.isArray(value) && value.every((v) => typeof v === "number") ? value : void 0,
13908
+ validationStatus,
13909
+ onChange: (id, nextValue) => onChange(id, nextValue)
13910
+ }
13911
+ );
13912
+ }
13132
13913
  return null;
13133
13914
  };
13134
13915
  const EPSILON = 10 ** -INTERNAL_PRECISION;
@@ -13631,6 +14412,102 @@ const CounterControls = ({
13631
14412
  ] }),
13632
14413
  counter.name && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "number-line-counter-label", children: counter.name })
13633
14414
  ] });
14415
+ const CONTENT_ROW_EXTRA_SPACE = 8;
14416
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
14417
+ function useMeasuredPositionContentHeight(positionIds) {
14418
+ const [heightPx, setHeightPx] = React.useState(null);
14419
+ const elementsRef = React.useRef(/* @__PURE__ */ new Map());
14420
+ const refCallbacksRef = React.useRef(/* @__PURE__ */ new Map());
14421
+ const observerRef = React.useRef(null);
14422
+ const measureFrameRef = React.useRef(null);
14423
+ const idsKey = positionIds.join("|");
14424
+ const runMeasure = React.useCallback(() => {
14425
+ let maxHeight = 0;
14426
+ for (const element of elementsRef.current.values()) {
14427
+ const rectHeight = element.getBoundingClientRect().height;
14428
+ const measuredHeight = Math.max(rectHeight, element.offsetHeight);
14429
+ maxHeight = Math.max(maxHeight, measuredHeight);
14430
+ }
14431
+ const nextHeight = maxHeight > 0 ? Math.ceil(maxHeight + CONTENT_ROW_EXTRA_SPACE) : null;
14432
+ setHeightPx((current) => current === nextHeight ? current : nextHeight);
14433
+ }, []);
14434
+ const measure = React.useCallback(() => {
14435
+ if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") {
14436
+ runMeasure();
14437
+ return;
14438
+ }
14439
+ if (measureFrameRef.current !== null) return;
14440
+ measureFrameRef.current = window.requestAnimationFrame(() => {
14441
+ measureFrameRef.current = null;
14442
+ runMeasure();
14443
+ });
14444
+ }, [runMeasure]);
14445
+ React.useEffect(
14446
+ () => () => {
14447
+ if (measureFrameRef.current !== null && typeof window !== "undefined") {
14448
+ window.cancelAnimationFrame(measureFrameRef.current);
14449
+ measureFrameRef.current = null;
14450
+ }
14451
+ },
14452
+ []
14453
+ );
14454
+ const getPositionContentRef = React.useCallback(
14455
+ (id) => {
14456
+ const cachedRef = refCallbacksRef.current.get(id);
14457
+ if (cachedRef) return cachedRef;
14458
+ const refCallback = (node) => {
14459
+ const current = elementsRef.current.get(id);
14460
+ if (current && observerRef.current) {
14461
+ observerRef.current.unobserve(current);
14462
+ }
14463
+ if (node) {
14464
+ elementsRef.current.set(id, node);
14465
+ observerRef.current?.observe(node);
14466
+ } else {
14467
+ elementsRef.current.delete(id);
14468
+ }
14469
+ measure();
14470
+ };
14471
+ refCallbacksRef.current.set(id, refCallback);
14472
+ return refCallback;
14473
+ },
14474
+ [measure]
14475
+ );
14476
+ React.useEffect(() => {
14477
+ const liveIds = new Set(positionIds);
14478
+ for (const id of elementsRef.current.keys()) {
14479
+ if (!liveIds.has(id)) {
14480
+ elementsRef.current.delete(id);
14481
+ }
14482
+ }
14483
+ for (const id of refCallbacksRef.current.keys()) {
14484
+ if (!liveIds.has(id)) {
14485
+ refCallbacksRef.current.delete(id);
14486
+ }
14487
+ }
14488
+ measure();
14489
+ }, [idsKey, measure]);
14490
+ useIsomorphicLayoutEffect(() => {
14491
+ if (typeof ResizeObserver === "undefined") {
14492
+ measure();
14493
+ return void 0;
14494
+ }
14495
+ const observer = new ResizeObserver(measure);
14496
+ observerRef.current = observer;
14497
+ for (const element of elementsRef.current.values()) {
14498
+ observer.observe(element);
14499
+ }
14500
+ measure();
14501
+ return () => {
14502
+ observer.disconnect();
14503
+ observerRef.current = null;
14504
+ };
14505
+ }, [idsKey, measure]);
14506
+ return {
14507
+ positionContentHeight: heightPx === null ? void 0 : `${heightPx}px`,
14508
+ getPositionContentRef
14509
+ };
14510
+ }
13634
14511
  const NumberLineRenderer = ({
13635
14512
  item,
13636
14513
  widgets,
@@ -13685,17 +14562,26 @@ const NumberLineRenderer = ({
13685
14562
  () => positions.filter((pos) => !isDragMode || getPositionWidgetId(pos.content) === null),
13686
14563
  [isDragMode, positions]
13687
14564
  );
14565
+ const visiblePositionIds = React.useMemo(
14566
+ () => visiblePositions.map((pos) => pos.id),
14567
+ [visiblePositions]
14568
+ );
14569
+ const { positionContentHeight, getPositionContentRef } = useMeasuredPositionContentHeight(visiblePositionIds);
13688
14570
  return /* @__PURE__ */ jsxRuntime.jsxs(
13689
14571
  "div",
13690
14572
  {
13691
14573
  className: `number-line${isDragMode ? " number-line--drag-mode" : ""}${lineStyle === "line" ? " number-line--style-line" : ""}`,
13692
- style: { [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%` },
14574
+ style: {
14575
+ [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%`,
14576
+ ...positionContentHeight && { "--nl-position-content-height": positionContentHeight }
14577
+ },
13693
14578
  role: "group",
13694
14579
  "aria-label": `Number line from ${min} to ${max}`,
13695
14580
  children: [
13696
14581
  visiblePositions.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-content-row", children: visiblePositions.map((pos) => /* @__PURE__ */ jsxRuntime.jsx(
13697
14582
  "div",
13698
14583
  {
14584
+ ref: getPositionContentRef(pos.id),
13699
14585
  className: "number-line-position-content",
13700
14586
  style: { left: `${toPercent(pos.value, min, max)}%` },
13701
14587
  children: pos.content.map((subItem) => /* @__PURE__ */ jsxRuntime.jsx(
@@ -13898,7 +14784,7 @@ const LayoutRenderer = ({
13898
14784
  if (!widget) return null;
13899
14785
  const isCorrect = validationDetails?.[item.widgetId];
13900
14786
  const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
13901
- const widgetKey = widget.type === JSON_TYPES.MATCHER ? `${item.widgetId}-${resetKey}` : item.widgetId;
14787
+ const widgetKey = widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.VISUAL_FRACTION ? `${item.widgetId}-${resetKey}` : item.widgetId;
13902
14788
  return /* @__PURE__ */ jsxRuntime.jsx(
13903
14789
  WidgetRenderer,
13904
14790
  {
@@ -14302,9 +15188,14 @@ exports.TextBlockSchema = TextBlockSchema;
14302
15188
  exports.TextItemSchema = TextItemSchema;
14303
15189
  exports.ToolNodeSchema = ToolNodeSchema;
14304
15190
  exports.TransformError = TransformError;
15191
+ exports.VISUAL_FRACTION_DEFAULT_SIZE = VISUAL_FRACTION_DEFAULT_SIZE;
15192
+ exports.VISUAL_FRACTION_MAX_DIVISIONS = VISUAL_FRACTION_MAX_DIVISIONS;
15193
+ exports.VISUAL_FRACTION_SHAPE = VISUAL_FRACTION_SHAPE;
15194
+ exports.VISUAL_FRACTION_SHAPES = VISUAL_FRACTION_SHAPES;
14305
15195
  exports.ValidationError = ValidationError;
14306
15196
  exports.ValidationJSONSchema = ValidationJSONSchema;
14307
15197
  exports.ValidationNodeSchema = ValidationNodeSchema;
15198
+ exports.VisualFractionInlineSchema = VisualFractionInlineSchema;
14308
15199
  exports.WidgetJSONSchema = WidgetJSONSchema;
14309
15200
  exports.WidgetRefSchema = WidgetRefSchema;
14310
15201
  exports.WidgetRenderer = WidgetRenderer;
@@ -14329,6 +15220,7 @@ exports.createSimpleValidation = createSimpleValidation;
14329
15220
  exports.createSpace = createSpace;
14330
15221
  exports.createStringInput = createStringInput;
14331
15222
  exports.createTextBlock = createTextBlock;
15223
+ exports.createVisualFraction = createVisualFraction;
14332
15224
  exports.evaluate = evaluate;
14333
15225
  exports.extractExpressionVariables = extractVariables;
14334
15226
  exports.findValidCombination = findValidCombination;
@@ -14372,6 +15264,7 @@ exports.isTextItem = isTextItem$1;
14372
15264
  exports.isToolNode = isToolNode;
14373
15265
  exports.isValidExpression = isValidExpression;
14374
15266
  exports.isValidationNode = isValidationNode;
15267
+ exports.isVisualFractionInline = isVisualFractionInline;
14375
15268
  exports.isWidgetRef = isWidgetRef;
14376
15269
  exports.parse = parse;
14377
15270
  exports.parseFractionKey = parseFractionKey;
@@ -14388,6 +15281,7 @@ exports.validateExerciseJSONSafe = validateExerciseJSONSafe;
14388
15281
  exports.validateExpression = validateExpression;
14389
15282
  exports.validateInputFormats = validateInputFormats;
14390
15283
  exports.validateSimple = validateSimple;
15284
+ exports.validateVisualFractionCapacity = validateVisualFractionCapacity;
14391
15285
  exports.validateWidgetJSON = validateWidgetJSON;
14392
15286
  exports.validateWidgetJSONSafe = validateWidgetJSONSafe;
14393
15287
  //# sourceMappingURL=index.js.map