@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.mjs CHANGED
@@ -23,6 +23,8 @@ const AST_TYPES = {
23
23
  INLINE_INPUT: "InlineInput",
24
24
  /** Inline fraction widget (numerator/denominator with optional inputs) */
25
25
  FRACTION: "Fraction",
26
+ /** Inline image element */
27
+ IMAGE: "Image",
26
28
  // ═══════════════════════════════════════════════════════════════════════════
27
29
  // Blocks
28
30
  // ═══════════════════════════════════════════════════════════════════════════
@@ -50,6 +52,8 @@ const AST_TYPES = {
50
52
  MATCHER_ROW: "MatcherRow",
51
53
  /** Item within a matcher row */
52
54
  MATCHER_ITEM: "MatcherItem",
55
+ /** Visual fraction inline widget */
56
+ VISUAL_FRACTION: "VisualFraction",
53
57
  // ═══════════════════════════════════════════════════════════════════════════
54
58
  // Tools
55
59
  // ═══════════════════════════════════════════════════════════════════════════
@@ -88,6 +92,8 @@ const JSON_TYPES = {
88
92
  COLOR_TEXT: "color-text",
89
93
  /** Bold text */
90
94
  BOLD_TEXT: "bold-text",
95
+ /** Inline image */
96
+ IMAGE: "image",
91
97
  /** Reference to a widget */
92
98
  WIDGET_REF: "widget-ref",
93
99
  /** Line break element */
@@ -128,7 +134,9 @@ const JSON_TYPES = {
128
134
  /** Numeric keypad widget */
129
135
  NUMERIC_KEYPAD: "numeric-keypad",
130
136
  /** Inline fraction widget */
131
- FRACTION: "fraction"
137
+ FRACTION: "fraction",
138
+ /** Visual fraction widget */
139
+ VISUAL_FRACTION: "visual-fraction"
132
140
  };
133
141
  const DRAG_OPTIONS_WIDGET_ID = "_drag_options";
134
142
  const VALIDATION_MODES = {
@@ -262,6 +270,10 @@ const HIGHLIGHT = createToken({
262
270
  name: "HIGHLIGHT",
263
271
  pattern: /HIGHLIGHT(?!_TEXT)/
264
272
  });
273
+ const VISUAL_FRACTION = createToken({
274
+ name: "VISUAL_FRACTION",
275
+ pattern: /VISUAL_FRACTION/
276
+ });
265
277
  const FRACTION = createToken({
266
278
  name: "FRACTION",
267
279
  pattern: /FRACTION/
@@ -278,6 +290,10 @@ const ANSWERS = createToken({
278
290
  name: "ANSWERS",
279
291
  pattern: /ANSWERS/
280
292
  });
293
+ const IMAGE = createToken({
294
+ name: "IMAGE",
295
+ pattern: /IMAGE/
296
+ });
281
297
  const NumberLiteral = createToken({
282
298
  name: "NumberLiteral",
283
299
  pattern: /-?\d+(\.\d+)?/
@@ -387,10 +403,12 @@ const allTokens = [
387
403
  CELL,
388
404
  COUNTER,
389
405
  HIGHLIGHT,
406
+ VISUAL_FRACTION,
390
407
  FRACTION,
391
408
  MATCHER,
392
409
  ITEM,
393
410
  ANSWERS,
411
+ IMAGE,
394
412
  // Literals
395
413
  NumberLiteral,
396
414
  StringLiteral,
@@ -849,6 +867,10 @@ class MathExerciseParser extends CstParser {
849
867
  { ALT: () => this.SUBRULE(this.boldText) },
850
868
  // FRACTION (inline fraction widget)
851
869
  { ALT: () => this.SUBRULE(this.fraction) },
870
+ // IMAGE (inline image element)
871
+ { ALT: () => this.SUBRULE(this.image) },
872
+ // VISUAL_FRACTION — always allowed; display-only when answer=[] or absence of answer parameter, interactive when answer=[...] with values.
873
+ { ALT: () => this.SUBRULE(this.visualFraction) },
852
874
  // W_S_I (inline input widget) — excluded in display-only contexts (e.g. MATCHER ITEM)
853
875
  { GATE: () => !displayOnly, ALT: () => this.SUBRULE(this.inlineInput) }
854
876
  ]);
@@ -880,6 +902,47 @@ class MathExerciseParser extends CstParser {
880
902
  this.SUBRULE(this.parameterList);
881
903
  this.CONSUME(RBracket);
882
904
  });
905
+ this.image = this.RULE("image", () => {
906
+ this.CONSUME(IMAGE);
907
+ this.CONSUME(LBracket);
908
+ this.SUBRULE(this.parameterList);
909
+ this.CONSUME(RBracket);
910
+ });
911
+ this.visualFraction = this.RULE("visualFraction", () => {
912
+ this.CONSUME(VISUAL_FRACTION);
913
+ this.CONSUME(LBracket);
914
+ this.SUBRULE(this.parameterList);
915
+ this.CONSUME(RBracket);
916
+ });
917
+ this.numberArray = this.RULE("numberArray", () => {
918
+ this.CONSUME(LBracket);
919
+ this.MANY(() => {
920
+ this.CONSUME(Space);
921
+ });
922
+ this.OPTION(() => {
923
+ this.CONSUME(NumberLiteral, { LABEL: "items" });
924
+ this.MANY2(() => {
925
+ this.MANY3(() => {
926
+ this.CONSUME2(Space);
927
+ });
928
+ this.CONSUME(Comma);
929
+ this.MANY4(() => {
930
+ this.CONSUME3(Space);
931
+ });
932
+ this.CONSUME2(NumberLiteral, { LABEL: "items" });
933
+ });
934
+ this.OPTION2(() => {
935
+ this.MANY5(() => {
936
+ this.CONSUME4(Space);
937
+ });
938
+ this.CONSUME2(Comma);
939
+ });
940
+ });
941
+ this.MANY6(() => {
942
+ this.CONSUME5(Space);
943
+ });
944
+ this.CONSUME(RBracket);
945
+ });
883
946
  this.inlineInput = this.RULE("inlineInput", () => {
884
947
  this.OR([
885
948
  { ALT: () => this.CONSUME(W_S_I, { LABEL: "stringInput" }) },
@@ -967,7 +1030,9 @@ class MathExerciseParser extends CstParser {
967
1030
  // Tuple value: (start, end)
968
1031
  { ALT: () => this.SUBRULE(this.tuple, { LABEL: "value" }) },
969
1032
  // Curly-brace string list: {"a", "b", "c"}
970
- { ALT: () => this.SUBRULE(this.curlyStringList, { LABEL: "value" }) }
1033
+ { ALT: () => this.SUBRULE(this.curlyStringList, { LABEL: "value" }) },
1034
+ // Square-bracket number array: [1, 2, 3] — used by VISUAL_FRACTION filled/answer
1035
+ { ALT: () => this.SUBRULE(this.numberArray, { LABEL: "value" }) }
971
1036
  ]);
972
1037
  });
973
1038
  this.tuple = this.RULE("tuple", () => {
@@ -1023,6 +1088,25 @@ class MathExerciseParser extends CstParser {
1023
1088
  }
1024
1089
  }
1025
1090
  const parser = new MathExerciseParser();
1091
+ function parseOrThrow(schema, data, label) {
1092
+ const result = schema.safeParse(data);
1093
+ if (!result.success) {
1094
+ const issue = result.error.issues[0];
1095
+ if (!issue) throw new Error(`${label}: validation failed`);
1096
+ const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
1097
+ throw new Error(`${label}: ${path} ${issue.message}`);
1098
+ }
1099
+ return result.data;
1100
+ }
1101
+ const VISUAL_FRACTION_SHAPES = ["bar", "circle", "triangle", "grid"];
1102
+ const VISUAL_FRACTION_SHAPE = {
1103
+ BAR: "bar",
1104
+ CIRCLE: "circle",
1105
+ TRIANGLE: "triangle",
1106
+ GRID: "grid"
1107
+ };
1108
+ const VISUAL_FRACTION_DEFAULT_SIZE = 1.35;
1109
+ const VISUAL_FRACTION_MAX_DIVISIONS = 100;
1026
1110
  const FreeTextInlineSchema = z.object({
1027
1111
  type: z.literal(AST_TYPES.FREE_TEXT),
1028
1112
  value: z.string()
@@ -1052,6 +1136,7 @@ const InlineInputWidgetSchema = z.discriminatedUnion("inputType", [
1052
1136
  name: z.string(),
1053
1137
  inputType: z.literal(INPUT_TYPES.STRING),
1054
1138
  length: z.number().int().positive(),
1139
+ placeholder: z.string().max(32).optional(),
1055
1140
  answer: z.union([z.string(), z.number()]).optional()
1056
1141
  }),
1057
1142
  z.object({
@@ -1059,6 +1144,7 @@ const InlineInputWidgetSchema = z.discriminatedUnion("inputType", [
1059
1144
  name: z.string(),
1060
1145
  inputType: z.literal(INPUT_TYPES.NUMERIC),
1061
1146
  digits: z.number().int().positive(),
1147
+ placeholder: z.string().max(32).optional(),
1062
1148
  answer: z.union([z.string(), z.number()]).optional()
1063
1149
  })
1064
1150
  ]);
@@ -1070,6 +1156,71 @@ const FractionInlineSchema = z.object({
1070
1156
  answer: z.string().optional(),
1071
1157
  simplified: z.boolean().default(true)
1072
1158
  });
1159
+ const IMAGE_SRC_SCHEME_RE$1 = /^(https?:\/\/|data:image\/)/i;
1160
+ const ImageInlineSchema = z.object({
1161
+ type: z.literal(AST_TYPES.IMAGE),
1162
+ name: z.string().min(1),
1163
+ src: z.string().url().refine((s) => IMAGE_SRC_SCHEME_RE$1.test(s), {
1164
+ message: "src must use http(s) or data:image scheme"
1165
+ }),
1166
+ alt: z.string(),
1167
+ width: z.number().positive().optional(),
1168
+ height: z.number().positive().optional()
1169
+ });
1170
+ const VisualFractionInlineSchema = z.object({
1171
+ type: z.literal(AST_TYPES.VISUAL_FRACTION),
1172
+ name: z.string(),
1173
+ shape: z.enum(VISUAL_FRACTION_SHAPES).default("bar"),
1174
+ divisions: z.number().int().positive().max(VISUAL_FRACTION_MAX_DIVISIONS, {
1175
+ message: `divisions cannot exceed ${VISUAL_FRACTION_MAX_DIVISIONS}`
1176
+ }),
1177
+ filled: z.array(z.number().int().nonnegative()).default([]),
1178
+ answer: z.array(z.number().int().nonnegative()).default([]),
1179
+ orientation: z.enum(["horizontal", "vertical"]).optional(),
1180
+ label: z.string().optional(),
1181
+ /** Unified author size.
1182
+ * - bar / grid: overrides `--visual-fraction-part-size` (cell size).
1183
+ * - circle / triangle: maps to `--visual-fraction-size` with the renderer scale factor.
1184
+ * Omit to use CSS defaults. */
1185
+ size: z.number().positive().optional()
1186
+ }).superRefine(validateVisualFractionCapacity);
1187
+ function validateVisualFractionCapacity(data, ctx) {
1188
+ const filledTotal = data.filled.reduce((sum, count) => sum + count, 0);
1189
+ const answerTotal = data.answer.reduce((sum, count) => sum + count, 0);
1190
+ if (filledTotal > data.divisions) {
1191
+ ctx.addIssue({
1192
+ code: z.ZodIssueCode.custom,
1193
+ message: `filled total (${filledTotal}) cannot exceed divisions (${data.divisions})`,
1194
+ path: ["filled"]
1195
+ });
1196
+ }
1197
+ if (filledTotal + answerTotal > data.divisions) {
1198
+ ctx.addIssue({
1199
+ code: z.ZodIssueCode.custom,
1200
+ message: `filled + answer total (${filledTotal + answerTotal}) cannot exceed divisions (${data.divisions})`,
1201
+ path: ["answer"]
1202
+ });
1203
+ }
1204
+ const shape = data.shape;
1205
+ if (shape === "triangle" && !isPerfectSquare(data.divisions)) {
1206
+ ctx.addIssue({
1207
+ code: z.ZodIssueCode.custom,
1208
+ message: `triangle divisions (${data.divisions}) must be a perfect square to render equal triangle parts`,
1209
+ path: ["divisions"]
1210
+ });
1211
+ }
1212
+ if (shape === "grid" && !isPerfectSquare(data.divisions)) {
1213
+ ctx.addIssue({
1214
+ code: z.ZodIssueCode.custom,
1215
+ message: `grid divisions (${data.divisions}) must be a perfect square to render equal rows and columns`,
1216
+ path: ["divisions"]
1217
+ });
1218
+ }
1219
+ }
1220
+ function isPerfectSquare(value) {
1221
+ const root = Math.sqrt(value);
1222
+ return Number.isInteger(root);
1223
+ }
1073
1224
  const InlineNodeSchema = z.union([
1074
1225
  FreeTextInlineSchema,
1075
1226
  SpaceInlineSchema,
@@ -1077,7 +1228,9 @@ const InlineNodeSchema = z.union([
1077
1228
  ColorTextInlineSchema,
1078
1229
  BoldTextInlineSchema,
1079
1230
  InlineInputWidgetSchema,
1080
- FractionInlineSchema
1231
+ FractionInlineSchema,
1232
+ ImageInlineSchema,
1233
+ VisualFractionInlineSchema
1081
1234
  ]);
1082
1235
  function createFreeText(value) {
1083
1236
  return FreeTextInlineSchema.parse({
@@ -1112,22 +1265,33 @@ function createBoldText(value, highlight) {
1112
1265
  highlight
1113
1266
  });
1114
1267
  }
1115
- function createStringInput(name, length, answer) {
1116
- return InlineInputWidgetSchema.parse({
1268
+ function createStringInput(name, length, answer, placeholder) {
1269
+ return parseOrThrow(InlineInputWidgetSchema, {
1117
1270
  type: AST_TYPES.INLINE_INPUT,
1118
1271
  name,
1119
1272
  inputType: INPUT_TYPES.STRING,
1120
1273
  length,
1274
+ placeholder,
1121
1275
  answer
1122
- });
1276
+ }, "STRING_INPUT");
1123
1277
  }
1124
- function createNumericInput(name, digits, answer) {
1125
- return InlineInputWidgetSchema.parse({
1278
+ function createNumericInput(name, digits, answer, placeholder) {
1279
+ return parseOrThrow(InlineInputWidgetSchema, {
1126
1280
  type: AST_TYPES.INLINE_INPUT,
1127
1281
  name,
1128
1282
  inputType: INPUT_TYPES.NUMERIC,
1129
1283
  digits,
1284
+ placeholder,
1130
1285
  answer
1286
+ }, "NUMERIC_INPUT");
1287
+ }
1288
+ function createImage(name, src, alt, options = {}) {
1289
+ return ImageInlineSchema.parse({
1290
+ type: AST_TYPES.IMAGE,
1291
+ name,
1292
+ src,
1293
+ alt,
1294
+ ...options
1131
1295
  });
1132
1296
  }
1133
1297
  function createFraction(name, options = {}) {
@@ -1137,6 +1301,14 @@ function createFraction(name, options = {}) {
1137
1301
  ...options
1138
1302
  });
1139
1303
  }
1304
+ function createVisualFraction(name, divisions, options = {}) {
1305
+ return VisualFractionInlineSchema.parse({
1306
+ type: AST_TYPES.VISUAL_FRACTION,
1307
+ name,
1308
+ divisions,
1309
+ ...options
1310
+ });
1311
+ }
1140
1312
  function isFreeText(value) {
1141
1313
  return FreeTextInlineSchema.safeParse(value).success;
1142
1314
  }
@@ -1149,9 +1321,15 @@ function isInlineInput(value) {
1149
1321
  function isFraction(value) {
1150
1322
  return FractionInlineSchema.safeParse(value).success;
1151
1323
  }
1324
+ function isImage(value) {
1325
+ return ImageInlineSchema.safeParse(value).success;
1326
+ }
1152
1327
  function isInlineNode(value) {
1153
1328
  return InlineNodeSchema.safeParse(value).success;
1154
1329
  }
1330
+ function isVisualFractionInline(value) {
1331
+ return VisualFractionInlineSchema.safeParse(value).success;
1332
+ }
1155
1333
  const TextBlockSchema = z.object({
1156
1334
  type: z.literal(AST_TYPES.TEXT_BLOCK),
1157
1335
  value: z.string()
@@ -1347,13 +1525,19 @@ const DisplayFractionInlineSchema = FractionInlineSchema.refine(
1347
1525
  (f) => f.numerator !== void 0 && f.denominator !== void 0,
1348
1526
  { message: "FRACTION inside MATCHER ITEM must be in display mode (both numerator and denominator required)" }
1349
1527
  );
1528
+ const DisplayVisualFractionInlineSchema = VisualFractionInlineSchema.refine(
1529
+ (vf) => vf.answer.length === 0,
1530
+ { message: "VISUAL_FRACTION inside MATCHER ITEM must be display-only (answer must be empty: answer=[])" }
1531
+ );
1350
1532
  const DisplayInlineNodeSchema = z.union([
1351
1533
  FreeTextInlineSchema,
1352
1534
  SpaceInlineSchema,
1353
1535
  HighlightedTextInlineSchema,
1354
1536
  ColorTextInlineSchema,
1355
1537
  BoldTextInlineSchema,
1356
- DisplayFractionInlineSchema
1538
+ DisplayFractionInlineSchema,
1539
+ ImageInlineSchema,
1540
+ DisplayVisualFractionInlineSchema
1357
1541
  ]);
1358
1542
  const MatcherItemSchema = z.object({
1359
1543
  type: z.literal(AST_TYPES.MATCHER_ITEM),
@@ -1449,7 +1633,8 @@ const DragOptionsToolSchema = z.object({
1449
1633
  const NumericKeypadToolSchema = z.object({
1450
1634
  type: z.literal(AST_TYPES.NUMERIC_KEYPAD),
1451
1635
  includeOperators: z.boolean().optional(),
1452
- includeDecimal: z.boolean().optional()
1636
+ includeDecimal: z.boolean().optional(),
1637
+ style: z.enum(["diamond", "classic"]).optional()
1453
1638
  });
1454
1639
  const ToolNodeSchema = z.discriminatedUnion("type", [
1455
1640
  DragOptionsToolSchema,
@@ -1461,11 +1646,12 @@ function createDragOptions(options) {
1461
1646
  options
1462
1647
  });
1463
1648
  }
1464
- function createNumericKeypad(includeOperators, includeDecimal) {
1649
+ function createNumericKeypad(includeOperators, includeDecimal, style) {
1465
1650
  return NumericKeypadToolSchema.parse({
1466
1651
  type: AST_TYPES.NUMERIC_KEYPAD,
1467
1652
  includeOperators,
1468
- includeDecimal
1653
+ includeDecimal,
1654
+ style
1469
1655
  });
1470
1656
  }
1471
1657
  function isDragOptions(value) {
@@ -1529,6 +1715,51 @@ const ExerciseDocumentSchema = z.object({
1529
1715
  lines: z.array(ContentBlockSchema),
1530
1716
  tools: z.array(ToolNodeSchema).optional(),
1531
1717
  validation: ValidationNodeSchema.optional()
1718
+ }).superRefine((doc, ctx) => {
1719
+ const seen = /* @__PURE__ */ new Set();
1720
+ const record = (name, path) => {
1721
+ if (seen.has(name)) {
1722
+ ctx.addIssue({
1723
+ code: z.ZodIssueCode.custom,
1724
+ message: `Duplicate name "${name}" — widget/image names must be unique across the document`,
1725
+ path
1726
+ });
1727
+ } else {
1728
+ seen.add(name);
1729
+ }
1730
+ };
1731
+ const visitInlines = (inlines, basePath) => {
1732
+ inlines.forEach((inline, i) => {
1733
+ if (inline.type === AST_TYPES.INLINE_INPUT || inline.type === AST_TYPES.FRACTION || inline.type === AST_TYPES.IMAGE) {
1734
+ record(inline.name, [...basePath, i, "name"]);
1735
+ }
1736
+ });
1737
+ };
1738
+ doc.lines.forEach((block, bi) => {
1739
+ if (block.type === AST_TYPES.LINE_BLOCK) {
1740
+ visitInlines(block.inlines, ["lines", bi, "inlines"]);
1741
+ } else if (block.type === AST_TYPES.TABLE_BLOCK) {
1742
+ block.rows.forEach((row, ri) => {
1743
+ row.cells.forEach((cell, ci) => {
1744
+ visitInlines(cell.inlines, ["lines", bi, "rows", ri, "cells", ci, "inlines"]);
1745
+ });
1746
+ });
1747
+ } else if (block.type === AST_TYPES.NUMBER_LINE_BLOCK) {
1748
+ block.positions.forEach((pos, pi) => {
1749
+ visitInlines(pos.inlines, ["lines", bi, "positions", pi, "inlines"]);
1750
+ });
1751
+ } else if (block.type === AST_TYPES.MATCHER_BLOCK) {
1752
+ const m = block;
1753
+ record(m.name, ["lines", bi, "name"]);
1754
+ m.rows.forEach((row, ri) => {
1755
+ row.items.forEach((item, ii) => {
1756
+ visitInlines(item.inlines, ["lines", bi, "rows", ri, "items", ii, "inlines"]);
1757
+ });
1758
+ });
1759
+ } else if (block.type === AST_TYPES.RADIO_OPTIONS) {
1760
+ record(block.name, ["lines", bi, "name"]);
1761
+ }
1762
+ });
1532
1763
  });
1533
1764
  function createExerciseDocument(question, lines, validation, tools) {
1534
1765
  return ExerciseDocumentSchema.parse({
@@ -1771,6 +2002,12 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1771
2002
  if (ctx.fraction) {
1772
2003
  return this.visit(ctx.fraction);
1773
2004
  }
2005
+ if (ctx.image) {
2006
+ return this.visit(ctx.image);
2007
+ }
2008
+ if (ctx.visualFraction) {
2009
+ return this.visit(ctx.visualFraction);
2010
+ }
1774
2011
  if (ctx.inlineInput) {
1775
2012
  return this.visit(ctx.inlineInput);
1776
2013
  }
@@ -1813,16 +2050,41 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1813
2050
  const simplified = params.simplified !== void 0 ? params.simplified : void 0;
1814
2051
  return createFraction(name, { numerator, denominator, answer, simplified });
1815
2052
  }
2053
+ image(ctx) {
2054
+ const params = this.visit(ctx.parameterList);
2055
+ const name = params.name;
2056
+ const src = params.src;
2057
+ const alt = params.alt;
2058
+ const width = params.width;
2059
+ const height = params.height;
2060
+ return createImage(name, src, alt, { width, height });
2061
+ }
2062
+ visualFraction(ctx) {
2063
+ const params = this.visit(ctx.parameterList);
2064
+ const name = params.name;
2065
+ const divisions = params.divisions;
2066
+ const shape = params.shape;
2067
+ const filled = params.filled ?? [];
2068
+ const answer = params.answer ?? [];
2069
+ const orientation = params.orientation;
2070
+ const label = params.label !== void 0 ? params.label : void 0;
2071
+ const size = params.size !== void 0 ? params.size : void 0;
2072
+ return createVisualFraction(name, divisions, { shape, filled, answer, orientation, label, size });
2073
+ }
2074
+ numberArray(ctx) {
2075
+ return ctx.items ? ctx.items.map((t) => parseFloat(t.image)) : [];
2076
+ }
1816
2077
  inlineInput(ctx) {
1817
2078
  const params = this.visit(ctx.parameterList);
1818
2079
  const name = params.name;
1819
2080
  const answer = params.answer;
2081
+ const placeholder = params.placeholder;
1820
2082
  if (ctx.stringInput) {
1821
2083
  const length = params.length;
1822
- return createStringInput(name, length, answer);
2084
+ return createStringInput(name, length, answer, placeholder);
1823
2085
  }
1824
2086
  const digits = params.digits;
1825
- return createNumericInput(name, digits, answer);
2087
+ return createNumericInput(name, digits, answer, placeholder);
1826
2088
  }
1827
2089
  // ═══════════════════════════════════════════════════════════════════════════
1828
2090
  // Tools
@@ -1844,7 +2106,8 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1844
2106
  const params = ctx.parameterList ? this.visit(ctx.parameterList) : {};
1845
2107
  const includeOperators = params.includeOperators === true || params.includeOperators === "true";
1846
2108
  const includeDecimal = params.includeDecimal === true || params.includeDecimal === "true";
1847
- return createNumericKeypad(includeOperators || void 0, includeDecimal || void 0);
2109
+ const style = params.style;
2110
+ return createNumericKeypad(includeOperators || void 0, includeDecimal || void 0, style);
1848
2111
  }
1849
2112
  // ═══════════════════════════════════════════════════════════════════════════
1850
2113
  // Validation
@@ -1971,6 +2234,18 @@ const BoldTextSchema = z.object({
1971
2234
  value: z.string(),
1972
2235
  highlight: z.tuple([z.number(), z.number()]).optional()
1973
2236
  });
2237
+ const IMAGE_SRC_SCHEME_RE = /^(https?:\/\/|data:image\/)/i;
2238
+ const ImageItemSchema = z.object({
2239
+ id: z.string(),
2240
+ type: z.literal(JSON_TYPES.IMAGE),
2241
+ name: z.string().min(1),
2242
+ src: z.string().url().refine((s) => IMAGE_SRC_SCHEME_RE.test(s), {
2243
+ message: "src must use http(s) or data:image scheme"
2244
+ }),
2245
+ alt: z.string(),
2246
+ width: z.number().positive().optional(),
2247
+ height: z.number().positive().optional()
2248
+ });
1974
2249
  const SpaceItemSchema = z.object({
1975
2250
  id: z.string(),
1976
2251
  type: z.literal(JSON_TYPES.SPACE)
@@ -1993,6 +2268,7 @@ const LayoutItemSchema = z.lazy(
1993
2268
  HighlightedTextSchema,
1994
2269
  ColorTextSchema,
1995
2270
  BoldTextSchema,
2271
+ ImageItemSchema,
1996
2272
  SpaceItemSchema,
1997
2273
  LinebreakSchema,
1998
2274
  GroupItemSchema,
@@ -2074,12 +2350,14 @@ const MatcherRowItemSchema = z.object({
2074
2350
  const StringInputJSONSchema = z.object({
2075
2351
  type: z.literal(JSON_TYPES.INLINE_INPUT),
2076
2352
  inputType: z.literal(INPUT_TYPES.STRING),
2077
- length: z.number().int().positive()
2353
+ length: z.number().int().positive(),
2354
+ placeholder: z.string().max(32).optional()
2078
2355
  });
2079
2356
  const NumericInputJSONSchema = z.object({
2080
2357
  type: z.literal(JSON_TYPES.INLINE_INPUT),
2081
2358
  inputType: z.literal(INPUT_TYPES.NUMERIC),
2082
- digits: z.number().int().positive()
2359
+ digits: z.number().int().positive(),
2360
+ placeholder: z.string().max(32).optional()
2083
2361
  });
2084
2362
  const InlineInputJSONSchema = z.discriminatedUnion("inputType", [
2085
2363
  StringInputJSONSchema,
@@ -2096,7 +2374,8 @@ const RadioOptionsJSONSchema = z.object({
2096
2374
  const NumericKeypadJSONSchema = z.object({
2097
2375
  type: z.literal(JSON_TYPES.NUMERIC_KEYPAD),
2098
2376
  includeOperators: z.boolean().optional(),
2099
- includeDecimal: z.boolean().optional()
2377
+ includeDecimal: z.boolean().optional(),
2378
+ style: z.enum(["diamond", "classic"]).optional()
2100
2379
  });
2101
2380
  const FractionJSONSchema = z.object({
2102
2381
  type: z.literal(JSON_TYPES.FRACTION),
@@ -2112,24 +2391,44 @@ const MatcherWidgetJSONSchema = z.object({
2112
2391
  /** Number of answer groups — equals answers.length, stored here to avoid prop-drilling. */
2113
2392
  groupCount: z.number().int().min(1)
2114
2393
  });
2394
+ const VisualFractionJSONSchema = z.object({
2395
+ type: z.literal(JSON_TYPES.VISUAL_FRACTION),
2396
+ shape: z.enum(["bar", "circle", "triangle", "grid"]).default("bar"),
2397
+ divisions: z.number().int().positive().max(VISUAL_FRACTION_MAX_DIVISIONS, {
2398
+ message: `divisions cannot exceed ${VISUAL_FRACTION_MAX_DIVISIONS}`
2399
+ }),
2400
+ filled: z.array(z.number().int().nonnegative()),
2401
+ answer: z.array(z.number().int().nonnegative()),
2402
+ orientation: z.enum(["horizontal", "vertical"]).optional(),
2403
+ /** Optional label text rendered near the shape — replaces the old showLabel boolean */
2404
+ label: z.string().optional(),
2405
+ /** Overall shape size in unified author units.
2406
+ * - bar / grid: overrides `--visual-fraction-part-size` CSS custom property (cell size in rem).
2407
+ * - circle / triangle: overrides `--visual-fraction-size` through the renderer scale factor.
2408
+ */
2409
+ size: z.number().positive().optional()
2410
+ }).superRefine(validateVisualFractionCapacity);
2115
2411
  const WidgetJSONSchema = z.union([
2116
2412
  InlineInputJSONSchema,
2117
2413
  DragOptionsJSONSchema,
2118
2414
  RadioOptionsJSONSchema,
2119
2415
  NumericKeypadJSONSchema,
2120
2416
  FractionJSONSchema,
2121
- MatcherWidgetJSONSchema
2417
+ MatcherWidgetJSONSchema,
2418
+ VisualFractionJSONSchema
2122
2419
  ]);
2123
2420
  const ValidationJSONSchema = z.object({
2124
2421
  mode: z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION]),
2125
2422
  // Simple answers: string|number per widget id.
2126
2423
  // Matcher answers: string[][] keyed by matcher name. Outer/inner min(1)
2127
2424
  // mirrors MatcherBlockSchema invariants in src/ast/matcher.ts.
2425
+ // VisualFraction answers: number[] — one count per color group.
2128
2426
  answers: z.record(
2129
2427
  z.union([
2130
2428
  z.string(),
2131
2429
  z.number(),
2132
- z.array(z.array(z.string().min(1)).min(1)).min(1)
2430
+ z.array(z.array(z.string().min(1)).min(1)).min(1),
2431
+ z.array(z.number().int().nonnegative())
2133
2432
  ])
2134
2433
  ).optional(),
2135
2434
  expression: z.string().optional(),
@@ -2179,6 +2478,9 @@ function isColorText(item) {
2179
2478
  function isBoldText(item) {
2180
2479
  return item.type === JSON_TYPES.BOLD_TEXT;
2181
2480
  }
2481
+ function isImageItem(item) {
2482
+ return item.type === JSON_TYPES.IMAGE;
2483
+ }
2182
2484
  function isSpaceItem(item) {
2183
2485
  return item.type === JSON_TYPES.SPACE;
2184
2486
  }
@@ -2277,13 +2579,15 @@ function toJSON(ast) {
2277
2579
  widgets[inline.name] = {
2278
2580
  type: JSON_TYPES.INLINE_INPUT,
2279
2581
  inputType: INPUT_TYPES.STRING,
2280
- length: inline.length
2582
+ length: inline.length,
2583
+ placeholder: inline.placeholder
2281
2584
  };
2282
2585
  } else {
2283
2586
  widgets[inline.name] = {
2284
2587
  type: JSON_TYPES.INLINE_INPUT,
2285
2588
  inputType: INPUT_TYPES.NUMERIC,
2286
- digits: inline.digits
2589
+ digits: inline.digits,
2590
+ placeholder: inline.placeholder
2287
2591
  };
2288
2592
  }
2289
2593
  if (inline.answer !== void 0) {
@@ -2301,6 +2605,22 @@ function toJSON(ast) {
2301
2605
  answers[inline.name] = inline.answer;
2302
2606
  }
2303
2607
  }
2608
+ if (inline.type === AST_TYPES.VISUAL_FRACTION) {
2609
+ const answerArr = inline.answer ?? [];
2610
+ widgets[inline.name] = {
2611
+ type: JSON_TYPES.VISUAL_FRACTION,
2612
+ shape: inline.shape,
2613
+ divisions: inline.divisions,
2614
+ filled: inline.filled ?? [],
2615
+ answer: answerArr,
2616
+ ...inline.orientation !== void 0 && { orientation: inline.orientation },
2617
+ ...inline.label !== void 0 && { label: inline.label },
2618
+ ...inline.size !== void 0 && { size: inline.size }
2619
+ };
2620
+ if (answerArr.length > 0) {
2621
+ answers[inline.name] = answerArr;
2622
+ }
2623
+ }
2304
2624
  }
2305
2625
  for (const block of ast.lines) {
2306
2626
  if (block.type === AST_TYPES.RADIO_OPTIONS) {
@@ -2327,7 +2647,8 @@ function toJSON(ast) {
2327
2647
  widgets["_numeric_keypad"] = {
2328
2648
  type: JSON_TYPES.NUMERIC_KEYPAD,
2329
2649
  includeOperators: tool.includeOperators,
2330
- includeDecimal: tool.includeDecimal
2650
+ includeDecimal: tool.includeDecimal,
2651
+ style: tool.style
2331
2652
  };
2332
2653
  }
2333
2654
  }
@@ -2531,6 +2852,22 @@ function transformInline(inline, idGenerator) {
2531
2852
  type: JSON_TYPES.WIDGET_REF,
2532
2853
  widgetId: inline.name
2533
2854
  };
2855
+ case AST_TYPES.IMAGE:
2856
+ return {
2857
+ id: idGenerator.generate(JSON_TYPES.IMAGE),
2858
+ type: JSON_TYPES.IMAGE,
2859
+ name: inline.name,
2860
+ src: inline.src,
2861
+ alt: inline.alt,
2862
+ ...inline.width !== void 0 && { width: inline.width },
2863
+ ...inline.height !== void 0 && { height: inline.height }
2864
+ };
2865
+ case AST_TYPES.VISUAL_FRACTION:
2866
+ return {
2867
+ id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
2868
+ type: JSON_TYPES.WIDGET_REF,
2869
+ widgetId: inline.name
2870
+ };
2534
2871
  default:
2535
2872
  throw new Error(`Unknown inline type: ${inline.type}`);
2536
2873
  }
@@ -8073,6 +8410,11 @@ function areMatcherEqual(expected, result) {
8073
8410
  const normalize2 = (groups) => lodashExports.sortBy(groups.map((g) => lodashExports.sortBy(g)), (g) => g.join("|"));
8074
8411
  return lodashExports.isEqual(normalize2(expected), normalize2(result));
8075
8412
  }
8413
+ function areVisualFractionCountsEqual(expected, result) {
8414
+ if (!Array.isArray(result)) return false;
8415
+ if (expected.length !== result.length) return false;
8416
+ return lodashExports.isEqual(lodashExports.sortBy(expected), lodashExports.sortBy(result));
8417
+ }
8076
8418
  const math$1 = create(all);
8077
8419
  function collectNumberLines(items) {
8078
8420
  const result = [];
@@ -8121,6 +8463,15 @@ function validateSimple(exercise, userInputs) {
8121
8463
  else allCorrect = false;
8122
8464
  continue;
8123
8465
  }
8466
+ if (widget?.type === JSON_TYPES.VISUAL_FRACTION) {
8467
+ const userValue2 = userInputs[widgetId];
8468
+ const isCorrect2 = areVisualFractionCountsEqual(expected, userValue2);
8469
+ details[widgetId] = { widgetId, userValue: userValue2, isCorrect: isCorrect2 };
8470
+ totalCount++;
8471
+ if (isCorrect2) correctCount++;
8472
+ else allCorrect = false;
8473
+ continue;
8474
+ }
8124
8475
  totalCount++;
8125
8476
  const userValue = userInputs[widgetId];
8126
8477
  const isFractionLenient = widget?.type === JSON_TYPES.FRACTION && widget.simplified === false;
@@ -8627,10 +8978,10 @@ function getOwnerDocument(target) {
8627
8978
  }
8628
8979
  return document;
8629
8980
  }
8630
- const useIsomorphicLayoutEffect = canUseDOM ? useLayoutEffect : useEffect;
8981
+ const useIsomorphicLayoutEffect$1 = canUseDOM ? useLayoutEffect : useEffect;
8631
8982
  function useEvent(handler) {
8632
8983
  const handlerRef = useRef(handler);
8633
- useIsomorphicLayoutEffect(() => {
8984
+ useIsomorphicLayoutEffect$1(() => {
8634
8985
  handlerRef.current = handler;
8635
8986
  });
8636
8987
  return useCallback(function() {
@@ -8658,7 +9009,7 @@ function useLatestValue(value, dependencies) {
8658
9009
  dependencies = [value];
8659
9010
  }
8660
9011
  const valueRef = useRef(value);
8661
- useIsomorphicLayoutEffect(() => {
9012
+ useIsomorphicLayoutEffect$1(() => {
8662
9013
  if (valueRef.current !== value) {
8663
9014
  valueRef.current = value;
8664
9015
  }
@@ -10526,9 +10877,9 @@ function useResizeObserver(_ref) {
10526
10877
  return void 0;
10527
10878
  }
10528
10879
  const {
10529
- ResizeObserver
10880
+ ResizeObserver: ResizeObserver2
10530
10881
  } = window;
10531
- return new ResizeObserver(handleResize);
10882
+ return new ResizeObserver2(handleResize);
10532
10883
  },
10533
10884
  // eslint-disable-next-line react-hooks/exhaustive-deps
10534
10885
  [disabled]
@@ -10582,7 +10933,7 @@ function useRect(element, measure, fallbackRect) {
10582
10933
  const resizeObserver = useResizeObserver({
10583
10934
  callback: measureRect
10584
10935
  });
10585
- useIsomorphicLayoutEffect(() => {
10936
+ useIsomorphicLayoutEffect$1(() => {
10586
10937
  measureRect();
10587
10938
  if (element) {
10588
10939
  resizeObserver == null ? void 0 : resizeObserver.observe(element);
@@ -10756,7 +11107,7 @@ function useRects(elements, measure) {
10756
11107
  const resizeObserver = useResizeObserver({
10757
11108
  callback: measureRects
10758
11109
  });
10759
- useIsomorphicLayoutEffect(() => {
11110
+ useIsomorphicLayoutEffect$1(() => {
10760
11111
  resizeObserver == null ? void 0 : resizeObserver.disconnect();
10761
11112
  measureRects();
10762
11113
  elements.forEach((element) => resizeObserver == null ? void 0 : resizeObserver.observe(element));
@@ -11118,7 +11469,7 @@ function useLayoutShiftScrollCompensation(_ref) {
11118
11469
  x: config,
11119
11470
  y: config
11120
11471
  } : config;
11121
- useIsomorphicLayoutEffect(() => {
11472
+ useIsomorphicLayoutEffect$1(() => {
11122
11473
  const disabled = !x && !y;
11123
11474
  if (disabled || !activeNode) {
11124
11475
  initialized.current = false;
@@ -11479,7 +11830,7 @@ const DndContext = /* @__PURE__ */ memo(function DndContext2(_ref) {
11479
11830
  }, [draggableNodes, instantiateSensor]);
11480
11831
  const activators = useCombineActivators(sensors, bindActivatorToSensorInstantiator);
11481
11832
  useSensorSetup(sensors);
11482
- useIsomorphicLayoutEffect(() => {
11833
+ useIsomorphicLayoutEffect$1(() => {
11483
11834
  if (activeNodeRect && status === Status.Initializing) {
11484
11835
  setStatus(Status.Initialized);
11485
11836
  }
@@ -11563,7 +11914,7 @@ const DndContext = /* @__PURE__ */ memo(function DndContext2(_ref) {
11563
11914
  // eslint-disable-next-line react-hooks/exhaustive-deps
11564
11915
  [overId]
11565
11916
  );
11566
- useIsomorphicLayoutEffect(() => {
11917
+ useIsomorphicLayoutEffect$1(() => {
11567
11918
  sensorContext.current = {
11568
11919
  activatorEvent,
11569
11920
  active,
@@ -11690,7 +12041,7 @@ function useDraggable(_ref) {
11690
12041
  const [activatorNode, setActivatorNodeRef] = useNodeRef();
11691
12042
  const listeners = useSyntheticListeners(activators, id);
11692
12043
  const dataRef = useLatestValue(data);
11693
- useIsomorphicLayoutEffect(
12044
+ useIsomorphicLayoutEffect$1(
11694
12045
  () => {
11695
12046
  draggableNodes.set(id, {
11696
12047
  id,
@@ -11863,7 +12214,7 @@ function AnimationManager(_ref) {
11863
12214
  if (!children && !clonedChildren && previousChildren) {
11864
12215
  setClonedChildren(previousChildren);
11865
12216
  }
11866
- useIsomorphicLayoutEffect(() => {
12217
+ useIsomorphicLayoutEffect$1(() => {
11867
12218
  if (!element) {
11868
12219
  return;
11869
12220
  }
@@ -12545,6 +12896,7 @@ const InlineInput = ({
12545
12896
  inputType,
12546
12897
  length,
12547
12898
  digits,
12899
+ placeholder,
12548
12900
  onChange,
12549
12901
  onFocus
12550
12902
  }) => {
@@ -12590,19 +12942,88 @@ const InlineInput = ({
12590
12942
  onChange: (e) => handleChange(e.target.value, false),
12591
12943
  onFocus: () => onFocus?.(widgetId),
12592
12944
  maxLength: expectedLength,
12593
- style: { width: dynamicWidth }
12945
+ style: { width: dynamicWidth },
12946
+ placeholder
12594
12947
  }
12595
12948
  )
12596
12949
  )
12597
12950
  }
12598
12951
  );
12599
12952
  };
12953
+ const PillBackground = ({
12954
+ width = "100%",
12955
+ minHeight = "72px",
12956
+ fill = "#FFE7B4",
12957
+ children,
12958
+ className = "",
12959
+ style,
12960
+ ...rest
12961
+ }) => {
12962
+ return /* @__PURE__ */ jsxs(
12963
+ "div",
12964
+ {
12965
+ className: `pill-bg-wrapper ${className}`.trim(),
12966
+ style: {
12967
+ position: "relative",
12968
+ display: "inline-flex",
12969
+ alignItems: "center",
12970
+ justifyContent: "center",
12971
+ width,
12972
+ minHeight,
12973
+ ...style
12974
+ },
12975
+ ...rest,
12976
+ children: [
12977
+ /* @__PURE__ */ jsx(
12978
+ "svg",
12979
+ {
12980
+ viewBox: "0 0 510 108",
12981
+ preserveAspectRatio: "none",
12982
+ xmlns: "http://www.w3.org/2000/svg",
12983
+ "aria-hidden": "true",
12984
+ style: {
12985
+ position: "absolute",
12986
+ inset: 0,
12987
+ width: "100%",
12988
+ height: "100%",
12989
+ display: "block"
12990
+ },
12991
+ children: /* @__PURE__ */ jsx(
12992
+ "path",
12993
+ {
12994
+ 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",
12995
+ fill
12996
+ }
12997
+ )
12998
+ }
12999
+ ),
13000
+ /* @__PURE__ */ jsx(
13001
+ "div",
13002
+ {
13003
+ style: {
13004
+ position: "relative",
13005
+ zIndex: 1,
13006
+ display: "flex",
13007
+ alignItems: "center",
13008
+ justifyContent: "center",
13009
+ width: "100%",
13010
+ height: "100%",
13011
+ padding: "0 2rem",
13012
+ boxSizing: "border-box"
13013
+ },
13014
+ children
13015
+ }
13016
+ )
13017
+ ]
13018
+ }
13019
+ );
13020
+ };
12600
13021
  const DragOptions = ({
12601
13022
  options,
12602
13023
  onOptionClick
12603
13024
  }) => {
12604
13025
  const { usedOptionIds } = useExerciseDnd();
12605
- return /* @__PURE__ */ jsx("div", { className: "drag-options", children: options.map((option, idx) => {
13026
+ return /* @__PURE__ */ jsx(PillBackground, { children: /* @__PURE__ */ jsx("div", { className: "drag-options", children: options.map((option, idx) => {
12606
13027
  const optionId = `drag-option-${idx}`;
12607
13028
  const optionValue = String(option);
12608
13029
  const isUsed = usedOptionIds.has(optionId);
@@ -12629,7 +13050,7 @@ const DragOptions = ({
12629
13050
  }
12630
13051
  )
12631
13052
  ) }, optionId);
12632
- }) });
13053
+ }) }) });
12633
13054
  };
12634
13055
  const NumericKeypad = ({
12635
13056
  value,
@@ -12725,28 +13146,6 @@ const FractionInput = ({
12725
13146
  /* @__PURE__ */ jsx("span", { className: "fraction-denominator", children: renderSlot(denKey, denominator, denValue, "denominator") })
12726
13147
  ] });
12727
13148
  };
12728
- const RadioOptions = ({
12729
- name,
12730
- options,
12731
- value,
12732
- validationStatus = null,
12733
- onChange
12734
- }) => {
12735
- return /* @__PURE__ */ jsx("div", { className: "radio-options", children: options.map((option, idx) => {
12736
- const isSelected = value === option;
12737
- const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
12738
- return /* @__PURE__ */ jsx(
12739
- "button",
12740
- {
12741
- className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
12742
- onClick: () => onChange(name, option),
12743
- type: "button",
12744
- children: option
12745
- },
12746
- `${name}-radio-${idx}`
12747
- );
12748
- }) });
12749
- };
12750
13149
  function isLightColor(hex) {
12751
13150
  const clean = hex.replace("#", "");
12752
13151
  const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
@@ -12761,7 +13160,8 @@ function isLightColor(hex) {
12761
13160
  return 1.05 / (L + 0.05) < 4.5;
12762
13161
  }
12763
13162
  function getTextColorForBackground(color) {
12764
- if (color.startsWith("hsl(")) return "#1a1a1a";
13163
+ const hslLightness = parseHslLightness(color);
13164
+ if (hslLightness !== null) return hslLightness >= 60 ? "#1a1a1a" : "#ffffff";
12765
13165
  return isLightColor(color) ? "#1a1a1a" : "#ffffff";
12766
13166
  }
12767
13167
  function generateHslPalette(n) {
@@ -12770,7 +13170,7 @@ function generateHslPalette(n) {
12770
13170
  return `hsl(${hue}, 60%, 72%)`;
12771
13171
  });
12772
13172
  }
12773
- function resolveMatcherPalette(userColors, groupCount) {
13173
+ function resolveGroupPalette(userColors, groupCount) {
12774
13174
  if (groupCount <= 0) return [];
12775
13175
  if (!userColors || userColors.length === 0) {
12776
13176
  return generateHslPalette(groupCount);
@@ -12782,6 +13182,478 @@ function resolveMatcherPalette(userColors, groupCount) {
12782
13182
  const extras = generateHslPalette(needed);
12783
13183
  return [...userColors, ...extras];
12784
13184
  }
13185
+ function resolveDistinctGroupPalette(groupCount, options = {}) {
13186
+ if (groupCount <= 0) return [];
13187
+ const used = new Set((options.excludeColors ?? []).map(normalizeColorToken));
13188
+ const palette = [];
13189
+ for (const color of options.seedColors ?? []) {
13190
+ const token = normalizeColorToken(color);
13191
+ if (used.has(token)) continue;
13192
+ palette.push(color);
13193
+ used.add(token);
13194
+ if (palette.length === groupCount) return palette;
13195
+ }
13196
+ const addCandidates = (candidates) => {
13197
+ for (const color of candidates) {
13198
+ const token = normalizeColorToken(color);
13199
+ if (used.has(token)) continue;
13200
+ palette.push(color);
13201
+ used.add(token);
13202
+ if (palette.length === groupCount) return;
13203
+ }
13204
+ };
13205
+ addCandidates(generateHslPalette(groupCount));
13206
+ let candidateCount = groupCount + 1;
13207
+ while (palette.length < groupCount) {
13208
+ addCandidates(generateHslPalette(candidateCount++));
13209
+ }
13210
+ return palette;
13211
+ }
13212
+ function getGroupClassName(groupIndex) {
13213
+ return `widget-group-${groupIndex + 1}`;
13214
+ }
13215
+ function normalizeColorToken(color) {
13216
+ return color.replace(/\s+/g, "").toLowerCase();
13217
+ }
13218
+ function parseHslLightness(color) {
13219
+ const match = color.match(/^hsl\(\s*[\d.]+\s*,\s*[\d.]+%\s*,\s*([\d.]+)%\s*\)$/i);
13220
+ return match ? Number(match[1]) : null;
13221
+ }
13222
+ function sumCounts(counts) {
13223
+ return counts.reduce((sum, count) => sum + count, 0);
13224
+ }
13225
+ function countsToPartGroups(counts) {
13226
+ return counts.flatMap(
13227
+ (count, groupIndex) => Array.from({ length: count }, () => groupIndex)
13228
+ );
13229
+ }
13230
+ function normalizeCounts(value, expected) {
13231
+ if (!Array.isArray(value)) return expected.map(() => 0);
13232
+ return expected.map((max, i) => Math.max(0, Math.min(max, value[i] ?? 0)));
13233
+ }
13234
+ function serializeCounts(value) {
13235
+ return Array.isArray(value) ? value.join("|") : null;
13236
+ }
13237
+ function countsToAssignments(counts, divisions, filledCount) {
13238
+ const assignments = Array.from({ length: divisions }, () => null);
13239
+ let index = filledCount;
13240
+ counts.forEach((count, groupIndex) => {
13241
+ for (let i = 0; i < count && index < divisions; i++) {
13242
+ assignments[index] = groupIndex;
13243
+ index++;
13244
+ }
13245
+ });
13246
+ return assignments;
13247
+ }
13248
+ function getNextAssignments(assignments, partIndex, groupCount) {
13249
+ if (groupCount === 0) return [...assignments];
13250
+ const current = assignments[partIndex] ?? null;
13251
+ const next = current === null ? 0 : current + 1 >= groupCount ? null : current + 1;
13252
+ const updated = [...assignments];
13253
+ updated[partIndex] = next;
13254
+ return updated;
13255
+ }
13256
+ function assignmentsToCounts(assignments, groupCount) {
13257
+ const counts = Array.from({ length: groupCount }, () => 0);
13258
+ for (const groupIndex of assignments) {
13259
+ if (groupIndex !== null && groupIndex >= 0 && groupIndex < groupCount) {
13260
+ counts[groupIndex] = (counts[groupIndex] ?? 0) + 1;
13261
+ }
13262
+ }
13263
+ return counts;
13264
+ }
13265
+ function useVisualFractionState({
13266
+ widgetId,
13267
+ divisions,
13268
+ filledCount,
13269
+ answer,
13270
+ value,
13271
+ onChange
13272
+ }) {
13273
+ const [partAssignments, setPartAssignments] = useState(
13274
+ () => countsToAssignments(normalizeCounts(value, answer), divisions, filledCount)
13275
+ );
13276
+ const lastInternalValueRef = useRef(null);
13277
+ const valueKey = serializeCounts(value);
13278
+ useEffect(() => {
13279
+ if (valueKey !== null && valueKey === lastInternalValueRef.current) return;
13280
+ lastInternalValueRef.current = null;
13281
+ setPartAssignments(
13282
+ countsToAssignments(normalizeCounts(value, answer), divisions, filledCount)
13283
+ );
13284
+ }, [answer, divisions, filledCount, valueKey]);
13285
+ const handlePartClick = (partIndex) => {
13286
+ if (partIndex < filledCount) return;
13287
+ const updated = getNextAssignments(partAssignments, partIndex, answer.length);
13288
+ const nextCounts = assignmentsToCounts(updated, answer.length);
13289
+ lastInternalValueRef.current = serializeCounts(nextCounts);
13290
+ setPartAssignments(updated);
13291
+ onChange(widgetId, nextCounts);
13292
+ };
13293
+ return { partAssignments, handlePartClick };
13294
+ }
13295
+ const VISUAL_FRACTION_DISPLAY_COLOR = "#AE9CFF";
13296
+ const VISUAL_FRACTION_BORDER_COLOR = "#705DC0";
13297
+ const VISUAL_FRACTION_SVG_SIZE_SCALE = 8;
13298
+ function getCircleSectorProps(index, divisions) {
13299
+ if (divisions <= 1) return { kind: "circle" };
13300
+ const angle = 360 / divisions;
13301
+ const startAngle = -90 + index * angle;
13302
+ const endAngle = startAngle + angle;
13303
+ const start = polarToCartesian(50, 50, 48, endAngle);
13304
+ const end = polarToCartesian(50, 50, 48, startAngle);
13305
+ const largeArcFlag = angle > 180 ? 1 : 0;
13306
+ return {
13307
+ kind: "path",
13308
+ d: [
13309
+ "M",
13310
+ 50,
13311
+ 50,
13312
+ "L",
13313
+ start.x,
13314
+ start.y,
13315
+ "A",
13316
+ 48,
13317
+ 48,
13318
+ 0,
13319
+ largeArcFlag,
13320
+ 0,
13321
+ end.x,
13322
+ end.y,
13323
+ "Z"
13324
+ ].join(" ")
13325
+ };
13326
+ }
13327
+ function polarToCartesian(cx, cy, r, angleInDegrees) {
13328
+ const rad = angleInDegrees * Math.PI / 180;
13329
+ return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
13330
+ }
13331
+ function getEqualTrianglePartPath(index, divisions) {
13332
+ const side = Math.sqrt(divisions);
13333
+ if (!Number.isInteger(side)) {
13334
+ throw new Error(`getEqualTrianglePartPath: divisions (${divisions}) must be a perfect square.`);
13335
+ }
13336
+ const smallSide = 96 / side;
13337
+ const smallHeight = smallSide * Math.sqrt(3) / 2;
13338
+ const totalHeight = side * smallHeight;
13339
+ const offsetY = (100 - totalHeight) / 2;
13340
+ let cursor = 0;
13341
+ for (let row = 0; row < side; row++) {
13342
+ const y = offsetY + row * smallHeight;
13343
+ const rowLeft = 50 - (row + 1) * smallSide / 2;
13344
+ for (let col = 0; col <= row; col++) {
13345
+ if (cursor === index) {
13346
+ const x = rowLeft + col * smallSide;
13347
+ return ["M", x + smallSide / 2, y, "L", x + smallSide, y + smallHeight, "L", x, y + smallHeight, "Z"].join(" ");
13348
+ }
13349
+ cursor++;
13350
+ }
13351
+ for (let col = 0; col < row; col++) {
13352
+ if (cursor === index) {
13353
+ const x = rowLeft + col * smallSide;
13354
+ return ["M", x + smallSide, y + smallHeight, "L", x + smallSide / 2, y, "L", x + smallSide * 1.5, y, "Z"].join(" ");
13355
+ }
13356
+ cursor++;
13357
+ }
13358
+ }
13359
+ return "";
13360
+ }
13361
+ function getGridColumnCount(divisions) {
13362
+ return Math.ceil(Math.sqrt(divisions));
13363
+ }
13364
+ function getInteractiveProps(part, onPartClick) {
13365
+ if (!part.isInteractiveSlot) return {};
13366
+ return {
13367
+ onClick: () => onPartClick(part.index),
13368
+ role: "button",
13369
+ "aria-pressed": part.isSelected,
13370
+ "aria-label": getPartAriaLabel(part),
13371
+ tabIndex: 0,
13372
+ onKeyDown: (event) => {
13373
+ if (event.key === "Enter" || event.key === " ") {
13374
+ event.preventDefault();
13375
+ onPartClick(part.index);
13376
+ }
13377
+ }
13378
+ };
13379
+ }
13380
+ function getPartAriaLabel(part) {
13381
+ return part.groupIndex === null ? `Part ${part.index + 1}, empty` : `Part ${part.index + 1}, group ${part.groupIndex + 1}`;
13382
+ }
13383
+ const BarShape = ({
13384
+ parts,
13385
+ divisions,
13386
+ orientation,
13387
+ onPartClick
13388
+ }) => {
13389
+ const renderedParts = orientation === "vertical" ? [...parts].reverse() : parts;
13390
+ return /* @__PURE__ */ jsx(
13391
+ "div",
13392
+ {
13393
+ className: `visual-fraction-widget__parts${orientation === "vertical" ? " visual-fraction-widget__parts--vertical" : " visual-fraction-widget__parts--horizontal"}`,
13394
+ style: { "--visual-fraction-divisions": divisions },
13395
+ children: renderedParts.map((part) => /* @__PURE__ */ jsx(
13396
+ "div",
13397
+ {
13398
+ className: part.className,
13399
+ style: part.style,
13400
+ ...getInteractiveProps(part, onPartClick)
13401
+ },
13402
+ part.index
13403
+ ))
13404
+ }
13405
+ );
13406
+ };
13407
+ BarShape.displayName = "BarShape";
13408
+ const GridShape = ({
13409
+ parts,
13410
+ divisions,
13411
+ onPartClick
13412
+ }) => /* @__PURE__ */ jsx(
13413
+ "div",
13414
+ {
13415
+ className: "visual-fraction-widget__parts visual-fraction-widget__parts--horizontal",
13416
+ style: {
13417
+ "--visual-fraction-divisions": divisions,
13418
+ "--visual-fraction-grid-cols": getGridColumnCount(divisions)
13419
+ },
13420
+ children: parts.map((part) => /* @__PURE__ */ jsx(
13421
+ "div",
13422
+ {
13423
+ className: part.className,
13424
+ style: part.style,
13425
+ ...getInteractiveProps(part, onPartClick)
13426
+ },
13427
+ part.index
13428
+ ))
13429
+ }
13430
+ );
13431
+ GridShape.displayName = "GridShape";
13432
+ const CircleShape = ({
13433
+ parts,
13434
+ divisions,
13435
+ onPartClick
13436
+ }) => /* @__PURE__ */ jsx(
13437
+ "svg",
13438
+ {
13439
+ className: "visual-fraction-widget__shape visual-fraction-widget__circle",
13440
+ viewBox: "0 0 100 100",
13441
+ children: parts.map((part) => {
13442
+ const sectorProps = getCircleSectorProps(part.index, divisions);
13443
+ const className = `${part.className} visual-fraction-widget__sector`;
13444
+ const interactiveProps = getInteractiveProps(part, onPartClick);
13445
+ return sectorProps.kind === "circle" ? /* @__PURE__ */ jsx(
13446
+ "circle",
13447
+ {
13448
+ className,
13449
+ style: part.style,
13450
+ cx: "50",
13451
+ cy: "50",
13452
+ r: "48",
13453
+ ...interactiveProps
13454
+ },
13455
+ part.index
13456
+ ) : /* @__PURE__ */ jsx(
13457
+ "path",
13458
+ {
13459
+ className,
13460
+ style: part.style,
13461
+ d: sectorProps.d,
13462
+ ...interactiveProps
13463
+ },
13464
+ part.index
13465
+ );
13466
+ })
13467
+ }
13468
+ );
13469
+ CircleShape.displayName = "CircleShape";
13470
+ const TriangleShape = ({
13471
+ parts,
13472
+ divisions,
13473
+ onPartClick
13474
+ }) => /* @__PURE__ */ jsx(
13475
+ "svg",
13476
+ {
13477
+ className: "visual-fraction-widget__shape visual-fraction-widget__triangle",
13478
+ viewBox: "0 0 100 100",
13479
+ children: parts.map((part) => /* @__PURE__ */ jsx(
13480
+ "path",
13481
+ {
13482
+ className: `${part.className} visual-fraction-widget__sector`,
13483
+ style: part.style,
13484
+ d: getEqualTrianglePartPath(part.index, divisions),
13485
+ ...getInteractiveProps(part, onPartClick)
13486
+ },
13487
+ part.index
13488
+ ))
13489
+ }
13490
+ );
13491
+ TriangleShape.displayName = "TriangleShape";
13492
+ const ShapeBar = ({ parts, divisions, orientation, onPartClick }) => /* @__PURE__ */ jsx(BarShape, { parts, divisions, orientation, onPartClick });
13493
+ const ShapeCircle = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsx(CircleShape, { parts, divisions, onPartClick });
13494
+ const ShapeTriangle = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsx(TriangleShape, { parts, divisions, onPartClick });
13495
+ const ShapeGrid = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsx(GridShape, { parts, divisions, onPartClick });
13496
+ const SHAPE_COMPONENTS = {
13497
+ [VISUAL_FRACTION_SHAPE.BAR]: ShapeBar,
13498
+ [VISUAL_FRACTION_SHAPE.CIRCLE]: ShapeCircle,
13499
+ [VISUAL_FRACTION_SHAPE.TRIANGLE]: ShapeTriangle,
13500
+ [VISUAL_FRACTION_SHAPE.GRID]: ShapeGrid
13501
+ };
13502
+ const VisualFraction = ({
13503
+ widgetId,
13504
+ widget,
13505
+ value,
13506
+ validationStatus = null,
13507
+ onChange
13508
+ }) => {
13509
+ const { shape, divisions, filled, answer, orientation, label } = widget;
13510
+ const filledCount = sumCounts(filled);
13511
+ const filledPartGroups = useMemo(() => countsToPartGroups(filled), [filled]);
13512
+ const filledPalette = useMemo(() => {
13513
+ if (filled.length === 0) return [];
13514
+ return resolveDistinctGroupPalette(filled.length, {
13515
+ seedColors: [VISUAL_FRACTION_DISPLAY_COLOR],
13516
+ excludeColors: [VISUAL_FRACTION_BORDER_COLOR]
13517
+ });
13518
+ }, [filled]);
13519
+ const answerPalette = useMemo(() => {
13520
+ if (answer.length === 0) return [];
13521
+ return resolveDistinctGroupPalette(answer.length, {
13522
+ excludeColors: [VISUAL_FRACTION_BORDER_COLOR, ...filledPalette]
13523
+ });
13524
+ }, [answer, filledPalette]);
13525
+ const { partAssignments, handlePartClick } = useVisualFractionState({
13526
+ widgetId,
13527
+ divisions,
13528
+ filledCount,
13529
+ answer,
13530
+ value,
13531
+ onChange
13532
+ });
13533
+ const parts = useMemo(
13534
+ () => Array.from({ length: divisions }, (_, i) => {
13535
+ let partClass = "visual-fraction-widget__part visual-fraction-widget__part--empty";
13536
+ let groupIdx = null;
13537
+ if (i < filledCount) {
13538
+ groupIdx = filledPartGroups[i] ?? 0;
13539
+ partClass = "visual-fraction-widget__part visual-fraction-widget__part--filled";
13540
+ } else if (partAssignments[i] != null) {
13541
+ groupIdx = partAssignments[i];
13542
+ partClass = "visual-fraction-widget__part visual-fraction-widget__part--answer";
13543
+ }
13544
+ if (groupIdx !== null) {
13545
+ partClass += ` visual-fraction-widget__part--group-${groupIdx + 1} ${getGroupClassName(groupIdx)}`;
13546
+ }
13547
+ const colorKind = i < filledCount ? "filled" : "answer";
13548
+ const palette = colorKind === "filled" ? filledPalette : answerPalette;
13549
+ const fallbackColor = palette[groupIdx ?? 0];
13550
+ const style = groupIdx !== null ? {
13551
+ "--visual-fraction-part-color": `var(--visual-fraction-${colorKind}-group-${groupIdx + 1}-color, ${fallbackColor})`,
13552
+ "--visual-fraction-part-text-color": `var(--visual-fraction-${colorKind}-group-${groupIdx + 1}-text-color, ${getTextColorForBackground(fallbackColor)})`
13553
+ } : void 0;
13554
+ return {
13555
+ index: i,
13556
+ className: partClass,
13557
+ style,
13558
+ // A slot is interactive when there are answer groups AND it falls in the non-filled range.
13559
+ // answer.length counts groups (not total slots); the state hook handles per-slot cycling.
13560
+ isInteractiveSlot: answer.length > 0 && i >= filledCount,
13561
+ isSelected: groupIdx !== null,
13562
+ groupIndex: groupIdx
13563
+ };
13564
+ }),
13565
+ [divisions, filledCount, filledPartGroups, partAssignments, answer, filledPalette, answerPalette]
13566
+ );
13567
+ const statusClass = validationStatus ? ` visual-fraction-widget--${validationStatus} widget--${validationStatus}` : "";
13568
+ const rootStyle = useMemo(() => getSizeStyle(widget.size, shape), [widget.size, shape]);
13569
+ const ShapeComponent = SHAPE_COMPONENTS[shape];
13570
+ return /* @__PURE__ */ jsxs(
13571
+ "div",
13572
+ {
13573
+ className: `visual-fraction-widget visual-fraction-widget--${shape}${statusClass}`,
13574
+ style: rootStyle,
13575
+ role: "group",
13576
+ "aria-label": `${divisions} part ${shape} fraction`,
13577
+ children: [
13578
+ /* @__PURE__ */ jsx(
13579
+ ShapeComponent,
13580
+ {
13581
+ parts,
13582
+ divisions,
13583
+ orientation: orientation ?? "horizontal",
13584
+ onPartClick: handlePartClick
13585
+ }
13586
+ ),
13587
+ label && /* @__PURE__ */ jsx("span", { className: "visual-fraction-widget__label", children: label })
13588
+ ]
13589
+ }
13590
+ );
13591
+ };
13592
+ VisualFraction.displayName = "VisualFraction";
13593
+ function getSizeStyle(size, shape) {
13594
+ if (size === void 0) return void 0;
13595
+ if (shape === "circle" || shape === "triangle") {
13596
+ return {
13597
+ "--visual-fraction-size": `${size * VISUAL_FRACTION_SVG_SIZE_SCALE}rem`
13598
+ };
13599
+ }
13600
+ return {
13601
+ "--visual-fraction-part-size": `${size}rem`
13602
+ };
13603
+ }
13604
+ const RadioOptions = ({
13605
+ name,
13606
+ options,
13607
+ value,
13608
+ validationStatus = null,
13609
+ onChange
13610
+ }) => {
13611
+ return /* @__PURE__ */ jsx("div", { className: "radio-options", children: options.map((option, idx) => {
13612
+ const isSelected = value === option;
13613
+ const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
13614
+ return /* @__PURE__ */ jsx(
13615
+ "button",
13616
+ {
13617
+ className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
13618
+ onClick: () => onChange(name, option),
13619
+ type: "button",
13620
+ children: option
13621
+ },
13622
+ `${name}-radio-${idx}`
13623
+ );
13624
+ }) });
13625
+ };
13626
+ const Btn = ({
13627
+ label,
13628
+ extraClass = "keypad-btn-digit",
13629
+ onClick
13630
+ }) => /* @__PURE__ */ jsx("button", { className: `keypad-btn ${extraClass}`, onClick, children: /* @__PURE__ */ jsx("span", { children: label }) });
13631
+ const DiamondKeypad = ({ value, onChange }) => {
13632
+ const append = (ch) => onChange(value + ch);
13633
+ const backspace = () => onChange(value.slice(0, -1));
13634
+ return /* @__PURE__ */ jsxs("div", { className: "diamond-keypad", children: [
13635
+ /* @__PURE__ */ jsxs("div", { className: "diamond-keypad-row", children: [
13636
+ /* @__PURE__ */ jsx(Btn, { label: "1", onClick: () => append("1") }),
13637
+ /* @__PURE__ */ jsx(Btn, { label: "2", onClick: () => append("2") }),
13638
+ /* @__PURE__ */ jsx(Btn, { label: "3", onClick: () => append("3") })
13639
+ ] }),
13640
+ /* @__PURE__ */ jsxs("div", { className: "diamond-keypad-row", children: [
13641
+ /* @__PURE__ */ jsx(Btn, { label: "4", onClick: () => append("4") }),
13642
+ /* @__PURE__ */ jsx(Btn, { label: "5", onClick: () => append("5") }),
13643
+ /* @__PURE__ */ jsx(Btn, { label: "6", onClick: () => append("6") }),
13644
+ /* @__PURE__ */ jsx(Btn, { label: "7", onClick: () => append("7") })
13645
+ ] }),
13646
+ /* @__PURE__ */ jsxs("div", { className: "diamond-keypad-row", children: [
13647
+ /* @__PURE__ */ jsx(Btn, { label: "8", onClick: () => append("8") }),
13648
+ /* @__PURE__ */ jsx(Btn, { label: "9", onClick: () => append("9") }),
13649
+ /* @__PURE__ */ jsx(Btn, { label: "0", onClick: () => append("0") })
13650
+ ] }),
13651
+ /* @__PURE__ */ jsxs("div", { className: "diamond-keypad-row", children: [
13652
+ /* @__PURE__ */ jsx(Btn, { label: ".", extraClass: "keypad-btn-decimal", onClick: () => append(".") }),
13653
+ /* @__PURE__ */ jsx(Btn, { label: "⌫", extraClass: "keypad-btn-backspace", onClick: backspace })
13654
+ ] })
13655
+ ] });
13656
+ };
12785
13657
  function collectItemIds(rows) {
12786
13658
  return rows.flatMap((row) => row.items.map((item) => item.itemId));
12787
13659
  }
@@ -12823,7 +13695,7 @@ const MatcherWidget = ({
12823
13695
  const { rows, colors: userColors, groupCount } = widget;
12824
13696
  const widgetsMap = widgets ?? EMPTY_WIDGETS;
12825
13697
  const palette = useMemo(
12826
- () => resolveMatcherPalette(userColors, groupCount),
13698
+ () => resolveGroupPalette(userColors, groupCount),
12827
13699
  [userColors, groupCount]
12828
13700
  );
12829
13701
  const textColors = useMemo(
@@ -12839,13 +13711,21 @@ const MatcherWidget = ({
12839
13711
  setAssignments((prev) => {
12840
13712
  const current = prev[itemId] ?? null;
12841
13713
  const next = current === null ? 0 : current + 1 >= groupCount ? null : current + 1;
12842
- const updated = { ...prev, [itemId]: next };
12843
- onChange(widgetId, buildGroups(updated));
12844
- return updated;
13714
+ return { ...prev, [itemId]: next };
12845
13715
  });
12846
13716
  },
12847
- [groupCount, onChange, widgetId]
13717
+ [groupCount]
12848
13718
  );
13719
+ const onChangeRef = useRef(onChange);
13720
+ onChangeRef.current = onChange;
13721
+ const isMounted = useRef(false);
13722
+ useEffect(() => {
13723
+ if (!isMounted.current) {
13724
+ isMounted.current = true;
13725
+ return;
13726
+ }
13727
+ onChangeRef.current(widgetId, buildGroups(assignments));
13728
+ }, [assignments, widgetId]);
12849
13729
  const maxCols = useMemo(
12850
13730
  () => rows.length === 0 ? 1 : Math.max(...rows.map((r) => r.items.length)),
12851
13731
  [rows]
@@ -12866,9 +13746,9 @@ const MatcherWidget = ({
12866
13746
  {
12867
13747
  type: "button",
12868
13748
  className: `matcher-card${isSelected ? " matcher-card--selected" : ""}`,
12869
- style: color ? {
12870
- "--matcher-card-color": color,
12871
- "--matcher-card-text-color": textColors[colorIdx]
13749
+ style: colorIdx !== null && color ? {
13750
+ "--matcher-card-color": `var(--matcher-group-${colorIdx + 1}-color, ${color})`,
13751
+ "--matcher-card-text-color": `var(--matcher-group-${colorIdx + 1}-text-color, ${textColors[colorIdx]})`
12872
13752
  } : void 0,
12873
13753
  onClick: () => handleCardClick(item.itemId),
12874
13754
  "aria-pressed": isSelected,
@@ -12910,6 +13790,9 @@ function isFractionWidget(widget) {
12910
13790
  function isMatcherWidget(widget) {
12911
13791
  return widget.type === "matcher";
12912
13792
  }
13793
+ function isVisualFractionWidget(widget) {
13794
+ return widget.type === "visual-fraction";
13795
+ }
12913
13796
  const WidgetRenderer = ({
12914
13797
  widget,
12915
13798
  widgetId,
@@ -12935,6 +13818,7 @@ const WidgetRenderer = ({
12935
13818
  inputType: widget.inputType,
12936
13819
  length: widget.inputType === "string" ? widget.length : void 0,
12937
13820
  digits: widget.inputType === "numeric" ? widget.digits : void 0,
13821
+ placeholder: widget.placeholder,
12938
13822
  onChange,
12939
13823
  onFocus: onInputFocus
12940
13824
  }
@@ -12963,14 +13847,24 @@ const WidgetRenderer = ({
12963
13847
  );
12964
13848
  }
12965
13849
  if (isNumericKeypadWidget(widget)) {
13850
+ const keypadOnChange = onKeypadChange || (() => {
13851
+ });
13852
+ if (widget.style === "classic") {
13853
+ return /* @__PURE__ */ jsx(
13854
+ NumericKeypad,
13855
+ {
13856
+ value: strValue,
13857
+ includeOperators: widget.includeOperators,
13858
+ includeDecimal: widget.includeDecimal,
13859
+ onChange: keypadOnChange
13860
+ }
13861
+ );
13862
+ }
12966
13863
  return /* @__PURE__ */ jsx(
12967
- NumericKeypad,
13864
+ DiamondKeypad,
12968
13865
  {
12969
13866
  value: strValue,
12970
- includeOperators: widget.includeOperators,
12971
- includeDecimal: widget.includeDecimal,
12972
- onChange: onKeypadChange || (() => {
12973
- })
13867
+ onChange: keypadOnChange
12974
13868
  }
12975
13869
  );
12976
13870
  }
@@ -13002,6 +13896,18 @@ const WidgetRenderer = ({
13002
13896
  }
13003
13897
  );
13004
13898
  }
13899
+ if (isVisualFractionWidget(widget)) {
13900
+ return /* @__PURE__ */ jsx(
13901
+ VisualFraction,
13902
+ {
13903
+ widgetId,
13904
+ widget,
13905
+ value: Array.isArray(value) && value.every((v) => typeof v === "number") ? value : void 0,
13906
+ validationStatus,
13907
+ onChange: (id, nextValue) => onChange(id, nextValue)
13908
+ }
13909
+ );
13910
+ }
13005
13911
  return null;
13006
13912
  };
13007
13913
  const EPSILON = 10 ** -INTERNAL_PRECISION;
@@ -13504,6 +14410,102 @@ const CounterControls = ({
13504
14410
  ] }),
13505
14411
  counter.name && /* @__PURE__ */ jsx("span", { className: "number-line-counter-label", children: counter.name })
13506
14412
  ] });
14413
+ const CONTENT_ROW_EXTRA_SPACE = 8;
14414
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
14415
+ function useMeasuredPositionContentHeight(positionIds) {
14416
+ const [heightPx, setHeightPx] = useState(null);
14417
+ const elementsRef = useRef(/* @__PURE__ */ new Map());
14418
+ const refCallbacksRef = useRef(/* @__PURE__ */ new Map());
14419
+ const observerRef = useRef(null);
14420
+ const measureFrameRef = useRef(null);
14421
+ const idsKey = positionIds.join("|");
14422
+ const runMeasure = useCallback(() => {
14423
+ let maxHeight = 0;
14424
+ for (const element of elementsRef.current.values()) {
14425
+ const rectHeight = element.getBoundingClientRect().height;
14426
+ const measuredHeight = Math.max(rectHeight, element.offsetHeight);
14427
+ maxHeight = Math.max(maxHeight, measuredHeight);
14428
+ }
14429
+ const nextHeight = maxHeight > 0 ? Math.ceil(maxHeight + CONTENT_ROW_EXTRA_SPACE) : null;
14430
+ setHeightPx((current) => current === nextHeight ? current : nextHeight);
14431
+ }, []);
14432
+ const measure = useCallback(() => {
14433
+ if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") {
14434
+ runMeasure();
14435
+ return;
14436
+ }
14437
+ if (measureFrameRef.current !== null) return;
14438
+ measureFrameRef.current = window.requestAnimationFrame(() => {
14439
+ measureFrameRef.current = null;
14440
+ runMeasure();
14441
+ });
14442
+ }, [runMeasure]);
14443
+ useEffect(
14444
+ () => () => {
14445
+ if (measureFrameRef.current !== null && typeof window !== "undefined") {
14446
+ window.cancelAnimationFrame(measureFrameRef.current);
14447
+ measureFrameRef.current = null;
14448
+ }
14449
+ },
14450
+ []
14451
+ );
14452
+ const getPositionContentRef = useCallback(
14453
+ (id) => {
14454
+ const cachedRef = refCallbacksRef.current.get(id);
14455
+ if (cachedRef) return cachedRef;
14456
+ const refCallback = (node) => {
14457
+ const current = elementsRef.current.get(id);
14458
+ if (current && observerRef.current) {
14459
+ observerRef.current.unobserve(current);
14460
+ }
14461
+ if (node) {
14462
+ elementsRef.current.set(id, node);
14463
+ observerRef.current?.observe(node);
14464
+ } else {
14465
+ elementsRef.current.delete(id);
14466
+ }
14467
+ measure();
14468
+ };
14469
+ refCallbacksRef.current.set(id, refCallback);
14470
+ return refCallback;
14471
+ },
14472
+ [measure]
14473
+ );
14474
+ useEffect(() => {
14475
+ const liveIds = new Set(positionIds);
14476
+ for (const id of elementsRef.current.keys()) {
14477
+ if (!liveIds.has(id)) {
14478
+ elementsRef.current.delete(id);
14479
+ }
14480
+ }
14481
+ for (const id of refCallbacksRef.current.keys()) {
14482
+ if (!liveIds.has(id)) {
14483
+ refCallbacksRef.current.delete(id);
14484
+ }
14485
+ }
14486
+ measure();
14487
+ }, [idsKey, measure]);
14488
+ useIsomorphicLayoutEffect(() => {
14489
+ if (typeof ResizeObserver === "undefined") {
14490
+ measure();
14491
+ return void 0;
14492
+ }
14493
+ const observer = new ResizeObserver(measure);
14494
+ observerRef.current = observer;
14495
+ for (const element of elementsRef.current.values()) {
14496
+ observer.observe(element);
14497
+ }
14498
+ measure();
14499
+ return () => {
14500
+ observer.disconnect();
14501
+ observerRef.current = null;
14502
+ };
14503
+ }, [idsKey, measure]);
14504
+ return {
14505
+ positionContentHeight: heightPx === null ? void 0 : `${heightPx}px`,
14506
+ getPositionContentRef
14507
+ };
14508
+ }
13507
14509
  const NumberLineRenderer = ({
13508
14510
  item,
13509
14511
  widgets,
@@ -13558,17 +14560,26 @@ const NumberLineRenderer = ({
13558
14560
  () => positions.filter((pos) => !isDragMode || getPositionWidgetId(pos.content) === null),
13559
14561
  [isDragMode, positions]
13560
14562
  );
14563
+ const visiblePositionIds = useMemo(
14564
+ () => visiblePositions.map((pos) => pos.id),
14565
+ [visiblePositions]
14566
+ );
14567
+ const { positionContentHeight, getPositionContentRef } = useMeasuredPositionContentHeight(visiblePositionIds);
13561
14568
  return /* @__PURE__ */ jsxs(
13562
14569
  "div",
13563
14570
  {
13564
14571
  className: `number-line${isDragMode ? " number-line--drag-mode" : ""}${lineStyle === "line" ? " number-line--style-line" : ""}`,
13565
- style: { [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%` },
14572
+ style: {
14573
+ [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%`,
14574
+ ...positionContentHeight && { "--nl-position-content-height": positionContentHeight }
14575
+ },
13566
14576
  role: "group",
13567
14577
  "aria-label": `Number line from ${min} to ${max}`,
13568
14578
  children: [
13569
14579
  visiblePositions.length > 0 && /* @__PURE__ */ jsx("div", { className: "number-line-content-row", children: visiblePositions.map((pos) => /* @__PURE__ */ jsx(
13570
14580
  "div",
13571
14581
  {
14582
+ ref: getPositionContentRef(pos.id),
13572
14583
  className: "number-line-position-content",
13573
14584
  style: { left: `${toPercent(pos.value, min, max)}%` },
13574
14585
  children: pos.content.map((subItem) => /* @__PURE__ */ jsx(
@@ -13754,12 +14765,24 @@ const LayoutRenderer = ({
13754
14765
  }
13755
14766
  return /* @__PURE__ */ jsx("strong", { className: "bold-text text-item", dir: dirAttr, children: item.value });
13756
14767
  }
14768
+ case JSON_TYPES.IMAGE:
14769
+ return /* @__PURE__ */ jsx(
14770
+ "img",
14771
+ {
14772
+ className: "image-item text-item",
14773
+ src: item.src,
14774
+ alt: item.alt,
14775
+ ...item.width !== void 0 && { width: item.width },
14776
+ ...item.height !== void 0 && { height: item.height },
14777
+ loading: "lazy"
14778
+ }
14779
+ );
13757
14780
  case JSON_TYPES.WIDGET_REF: {
13758
14781
  const widget = widgets[item.widgetId];
13759
14782
  if (!widget) return null;
13760
14783
  const isCorrect = validationDetails?.[item.widgetId];
13761
14784
  const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
13762
- const widgetKey = widget.type === JSON_TYPES.MATCHER ? `${item.widgetId}-${resetKey}` : item.widgetId;
14785
+ const widgetKey = widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.VISUAL_FRACTION ? `${item.widgetId}-${resetKey}` : item.widgetId;
13763
14786
  return /* @__PURE__ */ jsx(
13764
14787
  WidgetRenderer,
13765
14788
  {
@@ -14129,6 +15152,8 @@ export {
14129
15152
  GroupItemSchema,
14130
15153
  HighlightedTextInlineSchema,
14131
15154
  HighlightedTextSchema,
15155
+ ImageInlineSchema,
15156
+ ImageItemSchema,
14132
15157
  InlineInputJSONSchema,
14133
15158
  InlineInputWidgetSchema,
14134
15159
  InlineNodeSchema,
@@ -14162,9 +15187,14 @@ export {
14162
15187
  TextItemSchema,
14163
15188
  ToolNodeSchema,
14164
15189
  TransformError,
15190
+ VISUAL_FRACTION_DEFAULT_SIZE,
15191
+ VISUAL_FRACTION_MAX_DIVISIONS,
15192
+ VISUAL_FRACTION_SHAPE,
15193
+ VISUAL_FRACTION_SHAPES,
14165
15194
  ValidationError,
14166
15195
  ValidationJSONSchema,
14167
15196
  ValidationNodeSchema,
15197
+ VisualFractionInlineSchema,
14168
15198
  WidgetJSONSchema,
14169
15199
  WidgetRefSchema,
14170
15200
  WidgetRenderer,
@@ -14178,6 +15208,7 @@ export {
14178
15208
  createFraction,
14179
15209
  createFreeText,
14180
15210
  createHighlightedText,
15211
+ createImage,
14181
15212
  createLineBlock,
14182
15213
  createMatcherBlock,
14183
15214
  createMatcherItem,
@@ -14188,6 +15219,7 @@ export {
14188
15219
  createSpace,
14189
15220
  createStringInput,
14190
15221
  createTextBlock,
15222
+ createVisualFraction,
14191
15223
  evaluate,
14192
15224
  extractVariables as extractExpressionVariables,
14193
15225
  findValidCombination,
@@ -14205,6 +15237,8 @@ export {
14205
15237
  isFreeText,
14206
15238
  isGroupItem,
14207
15239
  isHighlightedText,
15240
+ isImage,
15241
+ isImageItem,
14208
15242
  isInlineInput,
14209
15243
  isInlineInputJSON,
14210
15244
  isInlineNode,
@@ -14229,6 +15263,7 @@ export {
14229
15263
  isToolNode,
14230
15264
  isValidExpression,
14231
15265
  isValidationNode,
15266
+ isVisualFractionInline,
14232
15267
  isWidgetRef,
14233
15268
  parse,
14234
15269
  parseFractionKey,
@@ -14245,6 +15280,7 @@ export {
14245
15280
  validateExpression,
14246
15281
  validateInputFormats,
14247
15282
  validateSimple,
15283
+ validateVisualFractionCapacity,
14248
15284
  validateWidgetJSON,
14249
15285
  validateWidgetJSONSafe
14250
15286
  };