@mathscan/math-exercise-engine 1.0.14 → 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
@@ -25,6 +25,8 @@ const AST_TYPES = {
25
25
  INLINE_INPUT: "InlineInput",
26
26
  /** Inline fraction widget (numerator/denominator with optional inputs) */
27
27
  FRACTION: "Fraction",
28
+ /** Inline image element */
29
+ IMAGE: "Image",
28
30
  // ═══════════════════════════════════════════════════════════════════════════
29
31
  // Blocks
30
32
  // ═══════════════════════════════════════════════════════════════════════════
@@ -52,6 +54,8 @@ const AST_TYPES = {
52
54
  MATCHER_ROW: "MatcherRow",
53
55
  /** Item within a matcher row */
54
56
  MATCHER_ITEM: "MatcherItem",
57
+ /** Visual fraction inline widget */
58
+ VISUAL_FRACTION: "VisualFraction",
55
59
  // ═══════════════════════════════════════════════════════════════════════════
56
60
  // Tools
57
61
  // ═══════════════════════════════════════════════════════════════════════════
@@ -90,6 +94,8 @@ const JSON_TYPES = {
90
94
  COLOR_TEXT: "color-text",
91
95
  /** Bold text */
92
96
  BOLD_TEXT: "bold-text",
97
+ /** Inline image */
98
+ IMAGE: "image",
93
99
  /** Reference to a widget */
94
100
  WIDGET_REF: "widget-ref",
95
101
  /** Line break element */
@@ -130,7 +136,9 @@ const JSON_TYPES = {
130
136
  /** Numeric keypad widget */
131
137
  NUMERIC_KEYPAD: "numeric-keypad",
132
138
  /** Inline fraction widget */
133
- FRACTION: "fraction"
139
+ FRACTION: "fraction",
140
+ /** Visual fraction widget */
141
+ VISUAL_FRACTION: "visual-fraction"
134
142
  };
135
143
  const DRAG_OPTIONS_WIDGET_ID = "_drag_options";
136
144
  const VALIDATION_MODES = {
@@ -264,6 +272,10 @@ const HIGHLIGHT = chevrotain.createToken({
264
272
  name: "HIGHLIGHT",
265
273
  pattern: /HIGHLIGHT(?!_TEXT)/
266
274
  });
275
+ const VISUAL_FRACTION = chevrotain.createToken({
276
+ name: "VISUAL_FRACTION",
277
+ pattern: /VISUAL_FRACTION/
278
+ });
267
279
  const FRACTION = chevrotain.createToken({
268
280
  name: "FRACTION",
269
281
  pattern: /FRACTION/
@@ -280,6 +292,10 @@ const ANSWERS = chevrotain.createToken({
280
292
  name: "ANSWERS",
281
293
  pattern: /ANSWERS/
282
294
  });
295
+ const IMAGE = chevrotain.createToken({
296
+ name: "IMAGE",
297
+ pattern: /IMAGE/
298
+ });
283
299
  const NumberLiteral = chevrotain.createToken({
284
300
  name: "NumberLiteral",
285
301
  pattern: /-?\d+(\.\d+)?/
@@ -389,10 +405,12 @@ const allTokens = [
389
405
  CELL,
390
406
  COUNTER,
391
407
  HIGHLIGHT,
408
+ VISUAL_FRACTION,
392
409
  FRACTION,
393
410
  MATCHER,
394
411
  ITEM,
395
412
  ANSWERS,
413
+ IMAGE,
396
414
  // Literals
397
415
  NumberLiteral,
398
416
  StringLiteral,
@@ -851,6 +869,10 @@ class MathExerciseParser extends chevrotain.CstParser {
851
869
  { ALT: () => this.SUBRULE(this.boldText) },
852
870
  // FRACTION (inline fraction widget)
853
871
  { ALT: () => this.SUBRULE(this.fraction) },
872
+ // IMAGE (inline image element)
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) },
854
876
  // W_S_I (inline input widget) — excluded in display-only contexts (e.g. MATCHER ITEM)
855
877
  { GATE: () => !displayOnly, ALT: () => this.SUBRULE(this.inlineInput) }
856
878
  ]);
@@ -882,6 +904,47 @@ class MathExerciseParser extends chevrotain.CstParser {
882
904
  this.SUBRULE(this.parameterList);
883
905
  this.CONSUME(RBracket);
884
906
  });
907
+ this.image = this.RULE("image", () => {
908
+ this.CONSUME(IMAGE);
909
+ this.CONSUME(LBracket);
910
+ this.SUBRULE(this.parameterList);
911
+ this.CONSUME(RBracket);
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
+ });
885
948
  this.inlineInput = this.RULE("inlineInput", () => {
886
949
  this.OR([
887
950
  { ALT: () => this.CONSUME(W_S_I, { LABEL: "stringInput" }) },
@@ -969,7 +1032,9 @@ class MathExerciseParser extends chevrotain.CstParser {
969
1032
  // Tuple value: (start, end)
970
1033
  { ALT: () => this.SUBRULE(this.tuple, { LABEL: "value" }) },
971
1034
  // Curly-brace string list: {"a", "b", "c"}
972
- { 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" }) }
973
1038
  ]);
974
1039
  });
975
1040
  this.tuple = this.RULE("tuple", () => {
@@ -1025,6 +1090,25 @@ class MathExerciseParser extends chevrotain.CstParser {
1025
1090
  }
1026
1091
  }
1027
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;
1028
1112
  const FreeTextInlineSchema = zod.z.object({
1029
1113
  type: zod.z.literal(AST_TYPES.FREE_TEXT),
1030
1114
  value: zod.z.string()
@@ -1054,6 +1138,7 @@ const InlineInputWidgetSchema = zod.z.discriminatedUnion("inputType", [
1054
1138
  name: zod.z.string(),
1055
1139
  inputType: zod.z.literal(INPUT_TYPES.STRING),
1056
1140
  length: zod.z.number().int().positive(),
1141
+ placeholder: zod.z.string().max(32).optional(),
1057
1142
  answer: zod.z.union([zod.z.string(), zod.z.number()]).optional()
1058
1143
  }),
1059
1144
  zod.z.object({
@@ -1061,6 +1146,7 @@ const InlineInputWidgetSchema = zod.z.discriminatedUnion("inputType", [
1061
1146
  name: zod.z.string(),
1062
1147
  inputType: zod.z.literal(INPUT_TYPES.NUMERIC),
1063
1148
  digits: zod.z.number().int().positive(),
1149
+ placeholder: zod.z.string().max(32).optional(),
1064
1150
  answer: zod.z.union([zod.z.string(), zod.z.number()]).optional()
1065
1151
  })
1066
1152
  ]);
@@ -1072,6 +1158,71 @@ const FractionInlineSchema = zod.z.object({
1072
1158
  answer: zod.z.string().optional(),
1073
1159
  simplified: zod.z.boolean().default(true)
1074
1160
  });
1161
+ const IMAGE_SRC_SCHEME_RE$1 = /^(https?:\/\/|data:image\/)/i;
1162
+ const ImageInlineSchema = zod.z.object({
1163
+ type: zod.z.literal(AST_TYPES.IMAGE),
1164
+ name: zod.z.string().min(1),
1165
+ src: zod.z.string().url().refine((s) => IMAGE_SRC_SCHEME_RE$1.test(s), {
1166
+ message: "src must use http(s) or data:image scheme"
1167
+ }),
1168
+ alt: zod.z.string(),
1169
+ width: zod.z.number().positive().optional(),
1170
+ height: zod.z.number().positive().optional()
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
+ }
1075
1226
  const InlineNodeSchema = zod.z.union([
1076
1227
  FreeTextInlineSchema,
1077
1228
  SpaceInlineSchema,
@@ -1079,7 +1230,9 @@ const InlineNodeSchema = zod.z.union([
1079
1230
  ColorTextInlineSchema,
1080
1231
  BoldTextInlineSchema,
1081
1232
  InlineInputWidgetSchema,
1082
- FractionInlineSchema
1233
+ FractionInlineSchema,
1234
+ ImageInlineSchema,
1235
+ VisualFractionInlineSchema
1083
1236
  ]);
1084
1237
  function createFreeText(value) {
1085
1238
  return FreeTextInlineSchema.parse({
@@ -1114,22 +1267,33 @@ function createBoldText(value, highlight) {
1114
1267
  highlight
1115
1268
  });
1116
1269
  }
1117
- function createStringInput(name, length, answer) {
1118
- return InlineInputWidgetSchema.parse({
1270
+ function createStringInput(name, length, answer, placeholder) {
1271
+ return parseOrThrow(InlineInputWidgetSchema, {
1119
1272
  type: AST_TYPES.INLINE_INPUT,
1120
1273
  name,
1121
1274
  inputType: INPUT_TYPES.STRING,
1122
1275
  length,
1276
+ placeholder,
1123
1277
  answer
1124
- });
1278
+ }, "STRING_INPUT");
1125
1279
  }
1126
- function createNumericInput(name, digits, answer) {
1127
- return InlineInputWidgetSchema.parse({
1280
+ function createNumericInput(name, digits, answer, placeholder) {
1281
+ return parseOrThrow(InlineInputWidgetSchema, {
1128
1282
  type: AST_TYPES.INLINE_INPUT,
1129
1283
  name,
1130
1284
  inputType: INPUT_TYPES.NUMERIC,
1131
1285
  digits,
1286
+ placeholder,
1132
1287
  answer
1288
+ }, "NUMERIC_INPUT");
1289
+ }
1290
+ function createImage(name, src, alt, options = {}) {
1291
+ return ImageInlineSchema.parse({
1292
+ type: AST_TYPES.IMAGE,
1293
+ name,
1294
+ src,
1295
+ alt,
1296
+ ...options
1133
1297
  });
1134
1298
  }
1135
1299
  function createFraction(name, options = {}) {
@@ -1139,6 +1303,14 @@ function createFraction(name, options = {}) {
1139
1303
  ...options
1140
1304
  });
1141
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
+ }
1142
1314
  function isFreeText(value) {
1143
1315
  return FreeTextInlineSchema.safeParse(value).success;
1144
1316
  }
@@ -1151,9 +1323,15 @@ function isInlineInput(value) {
1151
1323
  function isFraction(value) {
1152
1324
  return FractionInlineSchema.safeParse(value).success;
1153
1325
  }
1326
+ function isImage(value) {
1327
+ return ImageInlineSchema.safeParse(value).success;
1328
+ }
1154
1329
  function isInlineNode(value) {
1155
1330
  return InlineNodeSchema.safeParse(value).success;
1156
1331
  }
1332
+ function isVisualFractionInline(value) {
1333
+ return VisualFractionInlineSchema.safeParse(value).success;
1334
+ }
1157
1335
  const TextBlockSchema = zod.z.object({
1158
1336
  type: zod.z.literal(AST_TYPES.TEXT_BLOCK),
1159
1337
  value: zod.z.string()
@@ -1349,13 +1527,19 @@ const DisplayFractionInlineSchema = FractionInlineSchema.refine(
1349
1527
  (f) => f.numerator !== void 0 && f.denominator !== void 0,
1350
1528
  { message: "FRACTION inside MATCHER ITEM must be in display mode (both numerator and denominator required)" }
1351
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
+ );
1352
1534
  const DisplayInlineNodeSchema = zod.z.union([
1353
1535
  FreeTextInlineSchema,
1354
1536
  SpaceInlineSchema,
1355
1537
  HighlightedTextInlineSchema,
1356
1538
  ColorTextInlineSchema,
1357
1539
  BoldTextInlineSchema,
1358
- DisplayFractionInlineSchema
1540
+ DisplayFractionInlineSchema,
1541
+ ImageInlineSchema,
1542
+ DisplayVisualFractionInlineSchema
1359
1543
  ]);
1360
1544
  const MatcherItemSchema = zod.z.object({
1361
1545
  type: zod.z.literal(AST_TYPES.MATCHER_ITEM),
@@ -1451,7 +1635,8 @@ const DragOptionsToolSchema = zod.z.object({
1451
1635
  const NumericKeypadToolSchema = zod.z.object({
1452
1636
  type: zod.z.literal(AST_TYPES.NUMERIC_KEYPAD),
1453
1637
  includeOperators: zod.z.boolean().optional(),
1454
- includeDecimal: zod.z.boolean().optional()
1638
+ includeDecimal: zod.z.boolean().optional(),
1639
+ style: zod.z.enum(["diamond", "classic"]).optional()
1455
1640
  });
1456
1641
  const ToolNodeSchema = zod.z.discriminatedUnion("type", [
1457
1642
  DragOptionsToolSchema,
@@ -1463,11 +1648,12 @@ function createDragOptions(options) {
1463
1648
  options
1464
1649
  });
1465
1650
  }
1466
- function createNumericKeypad(includeOperators, includeDecimal) {
1651
+ function createNumericKeypad(includeOperators, includeDecimal, style) {
1467
1652
  return NumericKeypadToolSchema.parse({
1468
1653
  type: AST_TYPES.NUMERIC_KEYPAD,
1469
1654
  includeOperators,
1470
- includeDecimal
1655
+ includeDecimal,
1656
+ style
1471
1657
  });
1472
1658
  }
1473
1659
  function isDragOptions(value) {
@@ -1531,6 +1717,51 @@ const ExerciseDocumentSchema = zod.z.object({
1531
1717
  lines: zod.z.array(ContentBlockSchema),
1532
1718
  tools: zod.z.array(ToolNodeSchema).optional(),
1533
1719
  validation: ValidationNodeSchema.optional()
1720
+ }).superRefine((doc, ctx) => {
1721
+ const seen = /* @__PURE__ */ new Set();
1722
+ const record = (name, path) => {
1723
+ if (seen.has(name)) {
1724
+ ctx.addIssue({
1725
+ code: zod.z.ZodIssueCode.custom,
1726
+ message: `Duplicate name "${name}" — widget/image names must be unique across the document`,
1727
+ path
1728
+ });
1729
+ } else {
1730
+ seen.add(name);
1731
+ }
1732
+ };
1733
+ const visitInlines = (inlines, basePath) => {
1734
+ inlines.forEach((inline, i) => {
1735
+ if (inline.type === AST_TYPES.INLINE_INPUT || inline.type === AST_TYPES.FRACTION || inline.type === AST_TYPES.IMAGE) {
1736
+ record(inline.name, [...basePath, i, "name"]);
1737
+ }
1738
+ });
1739
+ };
1740
+ doc.lines.forEach((block, bi) => {
1741
+ if (block.type === AST_TYPES.LINE_BLOCK) {
1742
+ visitInlines(block.inlines, ["lines", bi, "inlines"]);
1743
+ } else if (block.type === AST_TYPES.TABLE_BLOCK) {
1744
+ block.rows.forEach((row, ri) => {
1745
+ row.cells.forEach((cell, ci) => {
1746
+ visitInlines(cell.inlines, ["lines", bi, "rows", ri, "cells", ci, "inlines"]);
1747
+ });
1748
+ });
1749
+ } else if (block.type === AST_TYPES.NUMBER_LINE_BLOCK) {
1750
+ block.positions.forEach((pos, pi) => {
1751
+ visitInlines(pos.inlines, ["lines", bi, "positions", pi, "inlines"]);
1752
+ });
1753
+ } else if (block.type === AST_TYPES.MATCHER_BLOCK) {
1754
+ const m = block;
1755
+ record(m.name, ["lines", bi, "name"]);
1756
+ m.rows.forEach((row, ri) => {
1757
+ row.items.forEach((item, ii) => {
1758
+ visitInlines(item.inlines, ["lines", bi, "rows", ri, "items", ii, "inlines"]);
1759
+ });
1760
+ });
1761
+ } else if (block.type === AST_TYPES.RADIO_OPTIONS) {
1762
+ record(block.name, ["lines", bi, "name"]);
1763
+ }
1764
+ });
1534
1765
  });
1535
1766
  function createExerciseDocument(question, lines, validation, tools) {
1536
1767
  return ExerciseDocumentSchema.parse({
@@ -1773,6 +2004,12 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1773
2004
  if (ctx.fraction) {
1774
2005
  return this.visit(ctx.fraction);
1775
2006
  }
2007
+ if (ctx.image) {
2008
+ return this.visit(ctx.image);
2009
+ }
2010
+ if (ctx.visualFraction) {
2011
+ return this.visit(ctx.visualFraction);
2012
+ }
1776
2013
  if (ctx.inlineInput) {
1777
2014
  return this.visit(ctx.inlineInput);
1778
2015
  }
@@ -1815,16 +2052,41 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1815
2052
  const simplified = params.simplified !== void 0 ? params.simplified : void 0;
1816
2053
  return createFraction(name, { numerator, denominator, answer, simplified });
1817
2054
  }
2055
+ image(ctx) {
2056
+ const params = this.visit(ctx.parameterList);
2057
+ const name = params.name;
2058
+ const src = params.src;
2059
+ const alt = params.alt;
2060
+ const width = params.width;
2061
+ const height = params.height;
2062
+ return createImage(name, src, alt, { width, height });
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
+ }
1818
2079
  inlineInput(ctx) {
1819
2080
  const params = this.visit(ctx.parameterList);
1820
2081
  const name = params.name;
1821
2082
  const answer = params.answer;
2083
+ const placeholder = params.placeholder;
1822
2084
  if (ctx.stringInput) {
1823
2085
  const length = params.length;
1824
- return createStringInput(name, length, answer);
2086
+ return createStringInput(name, length, answer, placeholder);
1825
2087
  }
1826
2088
  const digits = params.digits;
1827
- return createNumericInput(name, digits, answer);
2089
+ return createNumericInput(name, digits, answer, placeholder);
1828
2090
  }
1829
2091
  // ═══════════════════════════════════════════════════════════════════════════
1830
2092
  // Tools
@@ -1846,7 +2108,8 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1846
2108
  const params = ctx.parameterList ? this.visit(ctx.parameterList) : {};
1847
2109
  const includeOperators = params.includeOperators === true || params.includeOperators === "true";
1848
2110
  const includeDecimal = params.includeDecimal === true || params.includeDecimal === "true";
1849
- return createNumericKeypad(includeOperators || void 0, includeDecimal || void 0);
2111
+ const style = params.style;
2112
+ return createNumericKeypad(includeOperators || void 0, includeDecimal || void 0, style);
1850
2113
  }
1851
2114
  // ═══════════════════════════════════════════════════════════════════════════
1852
2115
  // Validation
@@ -1973,6 +2236,18 @@ const BoldTextSchema = zod.z.object({
1973
2236
  value: zod.z.string(),
1974
2237
  highlight: zod.z.tuple([zod.z.number(), zod.z.number()]).optional()
1975
2238
  });
2239
+ const IMAGE_SRC_SCHEME_RE = /^(https?:\/\/|data:image\/)/i;
2240
+ const ImageItemSchema = zod.z.object({
2241
+ id: zod.z.string(),
2242
+ type: zod.z.literal(JSON_TYPES.IMAGE),
2243
+ name: zod.z.string().min(1),
2244
+ src: zod.z.string().url().refine((s) => IMAGE_SRC_SCHEME_RE.test(s), {
2245
+ message: "src must use http(s) or data:image scheme"
2246
+ }),
2247
+ alt: zod.z.string(),
2248
+ width: zod.z.number().positive().optional(),
2249
+ height: zod.z.number().positive().optional()
2250
+ });
1976
2251
  const SpaceItemSchema = zod.z.object({
1977
2252
  id: zod.z.string(),
1978
2253
  type: zod.z.literal(JSON_TYPES.SPACE)
@@ -1995,6 +2270,7 @@ const LayoutItemSchema = zod.z.lazy(
1995
2270
  HighlightedTextSchema,
1996
2271
  ColorTextSchema,
1997
2272
  BoldTextSchema,
2273
+ ImageItemSchema,
1998
2274
  SpaceItemSchema,
1999
2275
  LinebreakSchema,
2000
2276
  GroupItemSchema,
@@ -2076,12 +2352,14 @@ const MatcherRowItemSchema = zod.z.object({
2076
2352
  const StringInputJSONSchema = zod.z.object({
2077
2353
  type: zod.z.literal(JSON_TYPES.INLINE_INPUT),
2078
2354
  inputType: zod.z.literal(INPUT_TYPES.STRING),
2079
- length: zod.z.number().int().positive()
2355
+ length: zod.z.number().int().positive(),
2356
+ placeholder: zod.z.string().max(32).optional()
2080
2357
  });
2081
2358
  const NumericInputJSONSchema = zod.z.object({
2082
2359
  type: zod.z.literal(JSON_TYPES.INLINE_INPUT),
2083
2360
  inputType: zod.z.literal(INPUT_TYPES.NUMERIC),
2084
- digits: zod.z.number().int().positive()
2361
+ digits: zod.z.number().int().positive(),
2362
+ placeholder: zod.z.string().max(32).optional()
2085
2363
  });
2086
2364
  const InlineInputJSONSchema = zod.z.discriminatedUnion("inputType", [
2087
2365
  StringInputJSONSchema,
@@ -2098,7 +2376,8 @@ const RadioOptionsJSONSchema = zod.z.object({
2098
2376
  const NumericKeypadJSONSchema = zod.z.object({
2099
2377
  type: zod.z.literal(JSON_TYPES.NUMERIC_KEYPAD),
2100
2378
  includeOperators: zod.z.boolean().optional(),
2101
- includeDecimal: zod.z.boolean().optional()
2379
+ includeDecimal: zod.z.boolean().optional(),
2380
+ style: zod.z.enum(["diamond", "classic"]).optional()
2102
2381
  });
2103
2382
  const FractionJSONSchema = zod.z.object({
2104
2383
  type: zod.z.literal(JSON_TYPES.FRACTION),
@@ -2114,24 +2393,44 @@ const MatcherWidgetJSONSchema = zod.z.object({
2114
2393
  /** Number of answer groups — equals answers.length, stored here to avoid prop-drilling. */
2115
2394
  groupCount: zod.z.number().int().min(1)
2116
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);
2117
2413
  const WidgetJSONSchema = zod.z.union([
2118
2414
  InlineInputJSONSchema,
2119
2415
  DragOptionsJSONSchema,
2120
2416
  RadioOptionsJSONSchema,
2121
2417
  NumericKeypadJSONSchema,
2122
2418
  FractionJSONSchema,
2123
- MatcherWidgetJSONSchema
2419
+ MatcherWidgetJSONSchema,
2420
+ VisualFractionJSONSchema
2124
2421
  ]);
2125
2422
  const ValidationJSONSchema = zod.z.object({
2126
2423
  mode: zod.z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION]),
2127
2424
  // Simple answers: string|number per widget id.
2128
2425
  // Matcher answers: string[][] keyed by matcher name. Outer/inner min(1)
2129
2426
  // mirrors MatcherBlockSchema invariants in src/ast/matcher.ts.
2427
+ // VisualFraction answers: number[] — one count per color group.
2130
2428
  answers: zod.z.record(
2131
2429
  zod.z.union([
2132
2430
  zod.z.string(),
2133
2431
  zod.z.number(),
2134
- 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())
2135
2434
  ])
2136
2435
  ).optional(),
2137
2436
  expression: zod.z.string().optional(),
@@ -2181,6 +2480,9 @@ function isColorText(item) {
2181
2480
  function isBoldText(item) {
2182
2481
  return item.type === JSON_TYPES.BOLD_TEXT;
2183
2482
  }
2483
+ function isImageItem(item) {
2484
+ return item.type === JSON_TYPES.IMAGE;
2485
+ }
2184
2486
  function isSpaceItem(item) {
2185
2487
  return item.type === JSON_TYPES.SPACE;
2186
2488
  }
@@ -2279,13 +2581,15 @@ function toJSON(ast) {
2279
2581
  widgets[inline.name] = {
2280
2582
  type: JSON_TYPES.INLINE_INPUT,
2281
2583
  inputType: INPUT_TYPES.STRING,
2282
- length: inline.length
2584
+ length: inline.length,
2585
+ placeholder: inline.placeholder
2283
2586
  };
2284
2587
  } else {
2285
2588
  widgets[inline.name] = {
2286
2589
  type: JSON_TYPES.INLINE_INPUT,
2287
2590
  inputType: INPUT_TYPES.NUMERIC,
2288
- digits: inline.digits
2591
+ digits: inline.digits,
2592
+ placeholder: inline.placeholder
2289
2593
  };
2290
2594
  }
2291
2595
  if (inline.answer !== void 0) {
@@ -2303,6 +2607,22 @@ function toJSON(ast) {
2303
2607
  answers[inline.name] = inline.answer;
2304
2608
  }
2305
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
+ }
2306
2626
  }
2307
2627
  for (const block of ast.lines) {
2308
2628
  if (block.type === AST_TYPES.RADIO_OPTIONS) {
@@ -2329,7 +2649,8 @@ function toJSON(ast) {
2329
2649
  widgets["_numeric_keypad"] = {
2330
2650
  type: JSON_TYPES.NUMERIC_KEYPAD,
2331
2651
  includeOperators: tool.includeOperators,
2332
- includeDecimal: tool.includeDecimal
2652
+ includeDecimal: tool.includeDecimal,
2653
+ style: tool.style
2333
2654
  };
2334
2655
  }
2335
2656
  }
@@ -2533,6 +2854,22 @@ function transformInline(inline, idGenerator) {
2533
2854
  type: JSON_TYPES.WIDGET_REF,
2534
2855
  widgetId: inline.name
2535
2856
  };
2857
+ case AST_TYPES.IMAGE:
2858
+ return {
2859
+ id: idGenerator.generate(JSON_TYPES.IMAGE),
2860
+ type: JSON_TYPES.IMAGE,
2861
+ name: inline.name,
2862
+ src: inline.src,
2863
+ alt: inline.alt,
2864
+ ...inline.width !== void 0 && { width: inline.width },
2865
+ ...inline.height !== void 0 && { height: inline.height }
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
+ };
2536
2873
  default:
2537
2874
  throw new Error(`Unknown inline type: ${inline.type}`);
2538
2875
  }
@@ -8075,6 +8412,11 @@ function areMatcherEqual(expected, result) {
8075
8412
  const normalize2 = (groups) => lodashExports.sortBy(groups.map((g) => lodashExports.sortBy(g)), (g) => g.join("|"));
8076
8413
  return lodashExports.isEqual(normalize2(expected), normalize2(result));
8077
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
+ }
8078
8420
  const math$1 = mathjs.create(mathjs.all);
8079
8421
  function collectNumberLines(items) {
8080
8422
  const result = [];
@@ -8123,6 +8465,15 @@ function validateSimple(exercise, userInputs) {
8123
8465
  else allCorrect = false;
8124
8466
  continue;
8125
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
+ }
8126
8477
  totalCount++;
8127
8478
  const userValue = userInputs[widgetId];
8128
8479
  const isFractionLenient = widget?.type === JSON_TYPES.FRACTION && widget.simplified === false;
@@ -8629,10 +8980,10 @@ function getOwnerDocument(target) {
8629
8980
  }
8630
8981
  return document;
8631
8982
  }
8632
- const useIsomorphicLayoutEffect = canUseDOM ? React.useLayoutEffect : React.useEffect;
8983
+ const useIsomorphicLayoutEffect$1 = canUseDOM ? React.useLayoutEffect : React.useEffect;
8633
8984
  function useEvent(handler) {
8634
8985
  const handlerRef = React.useRef(handler);
8635
- useIsomorphicLayoutEffect(() => {
8986
+ useIsomorphicLayoutEffect$1(() => {
8636
8987
  handlerRef.current = handler;
8637
8988
  });
8638
8989
  return React.useCallback(function() {
@@ -8660,7 +9011,7 @@ function useLatestValue(value, dependencies) {
8660
9011
  dependencies = [value];
8661
9012
  }
8662
9013
  const valueRef = React.useRef(value);
8663
- useIsomorphicLayoutEffect(() => {
9014
+ useIsomorphicLayoutEffect$1(() => {
8664
9015
  if (valueRef.current !== value) {
8665
9016
  valueRef.current = value;
8666
9017
  }
@@ -10528,9 +10879,9 @@ function useResizeObserver(_ref) {
10528
10879
  return void 0;
10529
10880
  }
10530
10881
  const {
10531
- ResizeObserver
10882
+ ResizeObserver: ResizeObserver2
10532
10883
  } = window;
10533
- return new ResizeObserver(handleResize);
10884
+ return new ResizeObserver2(handleResize);
10534
10885
  },
10535
10886
  // eslint-disable-next-line react-hooks/exhaustive-deps
10536
10887
  [disabled]
@@ -10584,7 +10935,7 @@ function useRect(element, measure, fallbackRect) {
10584
10935
  const resizeObserver = useResizeObserver({
10585
10936
  callback: measureRect
10586
10937
  });
10587
- useIsomorphicLayoutEffect(() => {
10938
+ useIsomorphicLayoutEffect$1(() => {
10588
10939
  measureRect();
10589
10940
  if (element) {
10590
10941
  resizeObserver == null ? void 0 : resizeObserver.observe(element);
@@ -10758,7 +11109,7 @@ function useRects(elements, measure) {
10758
11109
  const resizeObserver = useResizeObserver({
10759
11110
  callback: measureRects
10760
11111
  });
10761
- useIsomorphicLayoutEffect(() => {
11112
+ useIsomorphicLayoutEffect$1(() => {
10762
11113
  resizeObserver == null ? void 0 : resizeObserver.disconnect();
10763
11114
  measureRects();
10764
11115
  elements.forEach((element) => resizeObserver == null ? void 0 : resizeObserver.observe(element));
@@ -11120,7 +11471,7 @@ function useLayoutShiftScrollCompensation(_ref) {
11120
11471
  x: config,
11121
11472
  y: config
11122
11473
  } : config;
11123
- useIsomorphicLayoutEffect(() => {
11474
+ useIsomorphicLayoutEffect$1(() => {
11124
11475
  const disabled = !x && !y;
11125
11476
  if (disabled || !activeNode) {
11126
11477
  initialized.current = false;
@@ -11481,7 +11832,7 @@ const DndContext = /* @__PURE__ */ React.memo(function DndContext2(_ref) {
11481
11832
  }, [draggableNodes, instantiateSensor]);
11482
11833
  const activators = useCombineActivators(sensors, bindActivatorToSensorInstantiator);
11483
11834
  useSensorSetup(sensors);
11484
- useIsomorphicLayoutEffect(() => {
11835
+ useIsomorphicLayoutEffect$1(() => {
11485
11836
  if (activeNodeRect && status === Status.Initializing) {
11486
11837
  setStatus(Status.Initialized);
11487
11838
  }
@@ -11565,7 +11916,7 @@ const DndContext = /* @__PURE__ */ React.memo(function DndContext2(_ref) {
11565
11916
  // eslint-disable-next-line react-hooks/exhaustive-deps
11566
11917
  [overId]
11567
11918
  );
11568
- useIsomorphicLayoutEffect(() => {
11919
+ useIsomorphicLayoutEffect$1(() => {
11569
11920
  sensorContext.current = {
11570
11921
  activatorEvent,
11571
11922
  active,
@@ -11692,7 +12043,7 @@ function useDraggable(_ref) {
11692
12043
  const [activatorNode, setActivatorNodeRef] = useNodeRef();
11693
12044
  const listeners = useSyntheticListeners(activators, id);
11694
12045
  const dataRef = useLatestValue(data);
11695
- useIsomorphicLayoutEffect(
12046
+ useIsomorphicLayoutEffect$1(
11696
12047
  () => {
11697
12048
  draggableNodes.set(id, {
11698
12049
  id,
@@ -11865,7 +12216,7 @@ function AnimationManager(_ref) {
11865
12216
  if (!children && !clonedChildren && previousChildren) {
11866
12217
  setClonedChildren(previousChildren);
11867
12218
  }
11868
- useIsomorphicLayoutEffect(() => {
12219
+ useIsomorphicLayoutEffect$1(() => {
11869
12220
  if (!element) {
11870
12221
  return;
11871
12222
  }
@@ -12547,6 +12898,7 @@ const InlineInput = ({
12547
12898
  inputType,
12548
12899
  length,
12549
12900
  digits,
12901
+ placeholder,
12550
12902
  onChange,
12551
12903
  onFocus
12552
12904
  }) => {
@@ -12592,19 +12944,88 @@ const InlineInput = ({
12592
12944
  onChange: (e) => handleChange(e.target.value, false),
12593
12945
  onFocus: () => onFocus?.(widgetId),
12594
12946
  maxLength: expectedLength,
12595
- style: { width: dynamicWidth }
12947
+ style: { width: dynamicWidth },
12948
+ placeholder
12596
12949
  }
12597
12950
  )
12598
12951
  )
12599
12952
  }
12600
12953
  );
12601
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
+ };
12602
13023
  const DragOptions = ({
12603
13024
  options,
12604
13025
  onOptionClick
12605
13026
  }) => {
12606
13027
  const { usedOptionIds } = useExerciseDnd();
12607
- 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) => {
12608
13029
  const optionId = `drag-option-${idx}`;
12609
13030
  const optionValue = String(option);
12610
13031
  const isUsed = usedOptionIds.has(optionId);
@@ -12631,7 +13052,7 @@ const DragOptions = ({
12631
13052
  }
12632
13053
  )
12633
13054
  ) }, optionId);
12634
- }) });
13055
+ }) }) });
12635
13056
  };
12636
13057
  const NumericKeypad = ({
12637
13058
  value,
@@ -12727,28 +13148,6 @@ const FractionInput = ({
12727
13148
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-denominator", children: renderSlot(denKey, denominator, denValue, "denominator") })
12728
13149
  ] });
12729
13150
  };
12730
- const RadioOptions = ({
12731
- name,
12732
- options,
12733
- value,
12734
- validationStatus = null,
12735
- onChange
12736
- }) => {
12737
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "radio-options", children: options.map((option, idx) => {
12738
- const isSelected = value === option;
12739
- const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
12740
- return /* @__PURE__ */ jsxRuntime.jsx(
12741
- "button",
12742
- {
12743
- className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
12744
- onClick: () => onChange(name, option),
12745
- type: "button",
12746
- children: option
12747
- },
12748
- `${name}-radio-${idx}`
12749
- );
12750
- }) });
12751
- };
12752
13151
  function isLightColor(hex) {
12753
13152
  const clean = hex.replace("#", "");
12754
13153
  const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
@@ -12763,7 +13162,8 @@ function isLightColor(hex) {
12763
13162
  return 1.05 / (L + 0.05) < 4.5;
12764
13163
  }
12765
13164
  function getTextColorForBackground(color) {
12766
- if (color.startsWith("hsl(")) return "#1a1a1a";
13165
+ const hslLightness = parseHslLightness(color);
13166
+ if (hslLightness !== null) return hslLightness >= 60 ? "#1a1a1a" : "#ffffff";
12767
13167
  return isLightColor(color) ? "#1a1a1a" : "#ffffff";
12768
13168
  }
12769
13169
  function generateHslPalette(n) {
@@ -12772,7 +13172,7 @@ function generateHslPalette(n) {
12772
13172
  return `hsl(${hue}, 60%, 72%)`;
12773
13173
  });
12774
13174
  }
12775
- function resolveMatcherPalette(userColors, groupCount) {
13175
+ function resolveGroupPalette(userColors, groupCount) {
12776
13176
  if (groupCount <= 0) return [];
12777
13177
  if (!userColors || userColors.length === 0) {
12778
13178
  return generateHslPalette(groupCount);
@@ -12784,6 +13184,478 @@ function resolveMatcherPalette(userColors, groupCount) {
12784
13184
  const extras = generateHslPalette(needed);
12785
13185
  return [...userColors, ...extras];
12786
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
+ };
12787
13659
  function collectItemIds(rows) {
12788
13660
  return rows.flatMap((row) => row.items.map((item) => item.itemId));
12789
13661
  }
@@ -12825,7 +13697,7 @@ const MatcherWidget = ({
12825
13697
  const { rows, colors: userColors, groupCount } = widget;
12826
13698
  const widgetsMap = widgets ?? EMPTY_WIDGETS;
12827
13699
  const palette = React.useMemo(
12828
- () => resolveMatcherPalette(userColors, groupCount),
13700
+ () => resolveGroupPalette(userColors, groupCount),
12829
13701
  [userColors, groupCount]
12830
13702
  );
12831
13703
  const textColors = React.useMemo(
@@ -12841,13 +13713,21 @@ const MatcherWidget = ({
12841
13713
  setAssignments((prev) => {
12842
13714
  const current = prev[itemId] ?? null;
12843
13715
  const next = current === null ? 0 : current + 1 >= groupCount ? null : current + 1;
12844
- const updated = { ...prev, [itemId]: next };
12845
- onChange(widgetId, buildGroups(updated));
12846
- return updated;
13716
+ return { ...prev, [itemId]: next };
12847
13717
  });
12848
13718
  },
12849
- [groupCount, onChange, widgetId]
13719
+ [groupCount]
12850
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]);
12851
13731
  const maxCols = React.useMemo(
12852
13732
  () => rows.length === 0 ? 1 : Math.max(...rows.map((r) => r.items.length)),
12853
13733
  [rows]
@@ -12868,9 +13748,9 @@ const MatcherWidget = ({
12868
13748
  {
12869
13749
  type: "button",
12870
13750
  className: `matcher-card${isSelected ? " matcher-card--selected" : ""}`,
12871
- style: color ? {
12872
- "--matcher-card-color": color,
12873
- "--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]})`
12874
13754
  } : void 0,
12875
13755
  onClick: () => handleCardClick(item.itemId),
12876
13756
  "aria-pressed": isSelected,
@@ -12912,6 +13792,9 @@ function isFractionWidget(widget) {
12912
13792
  function isMatcherWidget(widget) {
12913
13793
  return widget.type === "matcher";
12914
13794
  }
13795
+ function isVisualFractionWidget(widget) {
13796
+ return widget.type === "visual-fraction";
13797
+ }
12915
13798
  const WidgetRenderer = ({
12916
13799
  widget,
12917
13800
  widgetId,
@@ -12937,6 +13820,7 @@ const WidgetRenderer = ({
12937
13820
  inputType: widget.inputType,
12938
13821
  length: widget.inputType === "string" ? widget.length : void 0,
12939
13822
  digits: widget.inputType === "numeric" ? widget.digits : void 0,
13823
+ placeholder: widget.placeholder,
12940
13824
  onChange,
12941
13825
  onFocus: onInputFocus
12942
13826
  }
@@ -12965,14 +13849,24 @@ const WidgetRenderer = ({
12965
13849
  );
12966
13850
  }
12967
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
+ }
12968
13865
  return /* @__PURE__ */ jsxRuntime.jsx(
12969
- NumericKeypad,
13866
+ DiamondKeypad,
12970
13867
  {
12971
13868
  value: strValue,
12972
- includeOperators: widget.includeOperators,
12973
- includeDecimal: widget.includeDecimal,
12974
- onChange: onKeypadChange || (() => {
12975
- })
13869
+ onChange: keypadOnChange
12976
13870
  }
12977
13871
  );
12978
13872
  }
@@ -13004,6 +13898,18 @@ const WidgetRenderer = ({
13004
13898
  }
13005
13899
  );
13006
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
+ }
13007
13913
  return null;
13008
13914
  };
13009
13915
  const EPSILON = 10 ** -INTERNAL_PRECISION;
@@ -13506,6 +14412,102 @@ const CounterControls = ({
13506
14412
  ] }),
13507
14413
  counter.name && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "number-line-counter-label", children: counter.name })
13508
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
+ }
13509
14511
  const NumberLineRenderer = ({
13510
14512
  item,
13511
14513
  widgets,
@@ -13560,17 +14562,26 @@ const NumberLineRenderer = ({
13560
14562
  () => positions.filter((pos) => !isDragMode || getPositionWidgetId(pos.content) === null),
13561
14563
  [isDragMode, positions]
13562
14564
  );
14565
+ const visiblePositionIds = React.useMemo(
14566
+ () => visiblePositions.map((pos) => pos.id),
14567
+ [visiblePositions]
14568
+ );
14569
+ const { positionContentHeight, getPositionContentRef } = useMeasuredPositionContentHeight(visiblePositionIds);
13563
14570
  return /* @__PURE__ */ jsxRuntime.jsxs(
13564
14571
  "div",
13565
14572
  {
13566
14573
  className: `number-line${isDragMode ? " number-line--drag-mode" : ""}${lineStyle === "line" ? " number-line--style-line" : ""}`,
13567
- style: { [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%` },
14574
+ style: {
14575
+ [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%`,
14576
+ ...positionContentHeight && { "--nl-position-content-height": positionContentHeight }
14577
+ },
13568
14578
  role: "group",
13569
14579
  "aria-label": `Number line from ${min} to ${max}`,
13570
14580
  children: [
13571
14581
  visiblePositions.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-content-row", children: visiblePositions.map((pos) => /* @__PURE__ */ jsxRuntime.jsx(
13572
14582
  "div",
13573
14583
  {
14584
+ ref: getPositionContentRef(pos.id),
13574
14585
  className: "number-line-position-content",
13575
14586
  style: { left: `${toPercent(pos.value, min, max)}%` },
13576
14587
  children: pos.content.map((subItem) => /* @__PURE__ */ jsxRuntime.jsx(
@@ -13756,12 +14767,24 @@ const LayoutRenderer = ({
13756
14767
  }
13757
14768
  return /* @__PURE__ */ jsxRuntime.jsx("strong", { className: "bold-text text-item", dir: dirAttr, children: item.value });
13758
14769
  }
14770
+ case JSON_TYPES.IMAGE:
14771
+ return /* @__PURE__ */ jsxRuntime.jsx(
14772
+ "img",
14773
+ {
14774
+ className: "image-item text-item",
14775
+ src: item.src,
14776
+ alt: item.alt,
14777
+ ...item.width !== void 0 && { width: item.width },
14778
+ ...item.height !== void 0 && { height: item.height },
14779
+ loading: "lazy"
14780
+ }
14781
+ );
13759
14782
  case JSON_TYPES.WIDGET_REF: {
13760
14783
  const widget = widgets[item.widgetId];
13761
14784
  if (!widget) return null;
13762
14785
  const isCorrect = validationDetails?.[item.widgetId];
13763
14786
  const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
13764
- 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;
13765
14788
  return /* @__PURE__ */ jsxRuntime.jsx(
13766
14789
  WidgetRenderer,
13767
14790
  {
@@ -14130,6 +15153,8 @@ exports.FreeTextInlineSchema = FreeTextInlineSchema;
14130
15153
  exports.GroupItemSchema = GroupItemSchema;
14131
15154
  exports.HighlightedTextInlineSchema = HighlightedTextInlineSchema;
14132
15155
  exports.HighlightedTextSchema = HighlightedTextSchema;
15156
+ exports.ImageInlineSchema = ImageInlineSchema;
15157
+ exports.ImageItemSchema = ImageItemSchema;
14133
15158
  exports.InlineInputJSONSchema = InlineInputJSONSchema;
14134
15159
  exports.InlineInputWidgetSchema = InlineInputWidgetSchema;
14135
15160
  exports.InlineNodeSchema = InlineNodeSchema;
@@ -14163,9 +15188,14 @@ exports.TextBlockSchema = TextBlockSchema;
14163
15188
  exports.TextItemSchema = TextItemSchema;
14164
15189
  exports.ToolNodeSchema = ToolNodeSchema;
14165
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;
14166
15195
  exports.ValidationError = ValidationError;
14167
15196
  exports.ValidationJSONSchema = ValidationJSONSchema;
14168
15197
  exports.ValidationNodeSchema = ValidationNodeSchema;
15198
+ exports.VisualFractionInlineSchema = VisualFractionInlineSchema;
14169
15199
  exports.WidgetJSONSchema = WidgetJSONSchema;
14170
15200
  exports.WidgetRefSchema = WidgetRefSchema;
14171
15201
  exports.WidgetRenderer = WidgetRenderer;
@@ -14179,6 +15209,7 @@ exports.createExpressionValidation = createExpressionValidation;
14179
15209
  exports.createFraction = createFraction;
14180
15210
  exports.createFreeText = createFreeText;
14181
15211
  exports.createHighlightedText = createHighlightedText;
15212
+ exports.createImage = createImage;
14182
15213
  exports.createLineBlock = createLineBlock;
14183
15214
  exports.createMatcherBlock = createMatcherBlock;
14184
15215
  exports.createMatcherItem = createMatcherItem;
@@ -14189,6 +15220,7 @@ exports.createSimpleValidation = createSimpleValidation;
14189
15220
  exports.createSpace = createSpace;
14190
15221
  exports.createStringInput = createStringInput;
14191
15222
  exports.createTextBlock = createTextBlock;
15223
+ exports.createVisualFraction = createVisualFraction;
14192
15224
  exports.evaluate = evaluate;
14193
15225
  exports.extractExpressionVariables = extractVariables;
14194
15226
  exports.findValidCombination = findValidCombination;
@@ -14206,6 +15238,8 @@ exports.isFractionKey = isFractionKey;
14206
15238
  exports.isFreeText = isFreeText;
14207
15239
  exports.isGroupItem = isGroupItem;
14208
15240
  exports.isHighlightedText = isHighlightedText;
15241
+ exports.isImage = isImage;
15242
+ exports.isImageItem = isImageItem;
14209
15243
  exports.isInlineInput = isInlineInput;
14210
15244
  exports.isInlineInputJSON = isInlineInputJSON;
14211
15245
  exports.isInlineNode = isInlineNode;
@@ -14230,6 +15264,7 @@ exports.isTextItem = isTextItem$1;
14230
15264
  exports.isToolNode = isToolNode;
14231
15265
  exports.isValidExpression = isValidExpression;
14232
15266
  exports.isValidationNode = isValidationNode;
15267
+ exports.isVisualFractionInline = isVisualFractionInline;
14233
15268
  exports.isWidgetRef = isWidgetRef;
14234
15269
  exports.parse = parse;
14235
15270
  exports.parseFractionKey = parseFractionKey;
@@ -14246,6 +15281,7 @@ exports.validateExerciseJSONSafe = validateExerciseJSONSafe;
14246
15281
  exports.validateExpression = validateExpression;
14247
15282
  exports.validateInputFormats = validateInputFormats;
14248
15283
  exports.validateSimple = validateSimple;
15284
+ exports.validateVisualFractionCapacity = validateVisualFractionCapacity;
14249
15285
  exports.validateWidgetJSON = validateWidgetJSON;
14250
15286
  exports.validateWidgetJSONSafe = validateWidgetJSONSafe;
14251
15287
  //# sourceMappingURL=index.js.map