@mathscan/math-exercise-engine 1.1.15 → 1.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
- const chevrotain = require("chevrotain");
4
3
  const zod = require("zod");
4
+ const chevrotain = require("chevrotain");
5
5
  const mathjs = require("mathjs");
6
6
  const jsxRuntime = require("react/jsx-runtime");
7
7
  const React = require("react");
@@ -56,6 +56,8 @@ const AST_TYPES = {
56
56
  VOP_CARRY_ROW: "VopCarryRow",
57
57
  /** Number line layout block */
58
58
  NUMBER_LINE_BLOCK: "NumberLineBlock",
59
+ /** Symmetry exercise on a square grid */
60
+ GRID_SYMMETRY_BLOCK: "GridSymmetryBlock",
59
61
  /** A position marker on the number line */
60
62
  NUMBER_LINE_POSITION: "NumberLinePosition",
61
63
  /** A draggable cursor on the number line */
@@ -187,6 +189,8 @@ const JSON_TYPES = {
187
189
  FRACTION: "fraction",
188
190
  /** Visual fraction widget */
189
191
  VISUAL_FRACTION: "visual-fraction",
192
+ /** Interactive symmetry exercise on a grid */
193
+ GRID_SYMMETRY: "grid-symmetry",
190
194
  /** Ruler geometry tool widget */
191
195
  RULER: "ruler",
192
196
  /** Protractor geometry tool widget */
@@ -201,9 +205,11 @@ const SET_SQUARE_WIDGET_ID = "_set_square";
201
205
  const STATEFUL_WIDGET_TYPES = /* @__PURE__ */ new Set([
202
206
  JSON_TYPES.MATCHER,
203
207
  JSON_TYPES.PAIR_MATCHER,
204
- JSON_TYPES.VISUAL_FRACTION
208
+ JSON_TYPES.VISUAL_FRACTION,
209
+ JSON_TYPES.GRID_SYMMETRY
205
210
  ]);
206
211
  const INLINE_DRAG_OPT_PREFIX = "_inline_drag_opt_";
212
+ const GRID_SYMMETRY_WIDGET_PREFIX = "_grid_symmetry_";
207
213
  const RTL_INLINE_WIDGET_TYPES = /* @__PURE__ */ new Set([
208
214
  JSON_TYPES.INLINE_INPUT,
209
215
  JSON_TYPES.INLINE_DRAG_OPTION,
@@ -231,6 +237,9 @@ const SIDE_PAD = 7;
231
237
  const CSS_VAR_SIDE_PAD = "--nl-side-pad";
232
238
  const NUMBER_LINE_LABELS = ["all", "extremes", "min-only", "max-only", "none"];
233
239
  const NUMBER_LINE_HIGHLIGHT_COLORS = ["blue", "red", "green", "orange", "purple", "yellow"];
240
+ const HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/;
241
+ const isNumberLineHighlightColor = (value) => NUMBER_LINE_HIGHLIGHT_COLORS.includes(value) || HEX_COLOR_REGEX.test(value);
242
+ const highlightColorMessage = (block) => `${block}: color must be one of ${NUMBER_LINE_HIGHLIGHT_COLORS.join(", ")} or a hex value (e.g. "#3B82F6")`;
234
243
  const NUMBER_LINE_DRAG_MODES = ["axis", "inputs"];
235
244
  function getFractionKey(widgetId, part) {
236
245
  return `${widgetId}__${part}`;
@@ -243,6 +252,11 @@ function parseFractionKey(key2) {
243
252
  if (!match) return null;
244
253
  return { widgetId: match[1], part: match[2] };
245
254
  }
255
+ const GridCellSchema = zod.z.tuple([
256
+ zod.z.number().int().positive(),
257
+ zod.z.number().int().positive()
258
+ ]);
259
+ const gridCellKey = ([row, col]) => `${row},${col}`;
246
260
  const Newline = chevrotain.createToken({
247
261
  name: "Newline",
248
262
  pattern: /\r?\n/,
@@ -324,6 +338,10 @@ const NUMBER_LINE = chevrotain.createToken({
324
338
  name: "NUMBER_LINE",
325
339
  pattern: /NUMBER_LINE/
326
340
  });
341
+ const GRID_SYMMETRY = chevrotain.createToken({
342
+ name: "GRID_SYMMETRY",
343
+ pattern: /GRID_SYMMETRY/
344
+ });
327
345
  const POSITION = chevrotain.createToken({
328
346
  name: "POSITION",
329
347
  pattern: /POSITION/
@@ -532,6 +550,7 @@ const allTokens = [
532
550
  W_N_I,
533
551
  NUMERIC_INPUT,
534
552
  NUMBER_LINE,
553
+ GRID_SYMMETRY,
535
554
  POSITION,
536
555
  CURSOR,
537
556
  ARROW,
@@ -707,6 +726,10 @@ class MathExerciseParser extends chevrotain.CstParser {
707
726
  GATE: this.BACKTRACK(this.numberLine),
708
727
  ALT: () => this.SUBRULE(this.numberLine)
709
728
  },
729
+ {
730
+ GATE: this.BACKTRACK(this.gridSymmetry),
731
+ ALT: () => this.SUBRULE(this.gridSymmetry)
732
+ },
710
733
  {
711
734
  GATE: this.BACKTRACK(this.pairMatcher),
712
735
  ALT: () => this.SUBRULE(this.pairMatcher)
@@ -722,6 +745,18 @@ class MathExerciseParser extends chevrotain.CstParser {
722
745
  { ALT: () => this.SUBRULE(this.line) }
723
746
  ]);
724
747
  });
748
+ this.gridSymmetry = this.RULE("gridSymmetry", () => {
749
+ this.MANY(() => {
750
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
751
+ });
752
+ this.CONSUME(GRID_SYMMETRY);
753
+ this.CONSUME(LBracket);
754
+ this.SUBRULE(this.parameterList);
755
+ this.CONSUME(RBracket);
756
+ this.MANY2(() => {
757
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
758
+ });
759
+ });
725
760
  this.table = this.RULE("table", () => {
726
761
  this.MANY(() => {
727
762
  this.OR2([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
@@ -1435,27 +1470,51 @@ class MathExerciseParser extends chevrotain.CstParser {
1435
1470
  });
1436
1471
  this.parameterList = this.RULE("parameterList", () => {
1437
1472
  this.MANY(() => {
1438
- this.CONSUME(Space);
1473
+ this.OR([
1474
+ { ALT: () => this.CONSUME(Space) },
1475
+ { ALT: () => this.CONSUME(Newline) }
1476
+ ]);
1439
1477
  });
1440
1478
  this.SUBRULE(this.parameter);
1441
1479
  this.MANY2(() => {
1442
1480
  this.MANY3(() => {
1443
- this.CONSUME2(Space);
1481
+ this.OR2([
1482
+ { ALT: () => this.CONSUME2(Space) },
1483
+ { ALT: () => this.CONSUME2(Newline) }
1484
+ ]);
1444
1485
  });
1445
1486
  this.CONSUME(Comma);
1446
1487
  this.MANY4(() => {
1447
- this.CONSUME3(Space);
1488
+ this.OR3([
1489
+ { ALT: () => this.CONSUME3(Space) },
1490
+ { ALT: () => this.CONSUME3(Newline) }
1491
+ ]);
1448
1492
  });
1449
1493
  this.SUBRULE2(this.parameter);
1450
1494
  });
1451
1495
  this.MANY5(() => {
1452
- this.CONSUME4(Space);
1496
+ this.OR4([
1497
+ { ALT: () => this.CONSUME4(Space) },
1498
+ { ALT: () => this.CONSUME4(Newline) }
1499
+ ]);
1453
1500
  });
1454
1501
  });
1455
1502
  this.parameter = this.RULE("parameter", () => {
1456
1503
  this.CONSUME(Identifier, { LABEL: "key" });
1504
+ this.MANY(() => {
1505
+ this.OR([
1506
+ { ALT: () => this.CONSUME(Space) },
1507
+ { ALT: () => this.CONSUME(Newline) }
1508
+ ]);
1509
+ });
1457
1510
  this.CONSUME(Equals);
1458
- this.OR([
1511
+ this.MANY2(() => {
1512
+ this.OR2([
1513
+ { ALT: () => this.CONSUME2(Space) },
1514
+ { ALT: () => this.CONSUME2(Newline) }
1515
+ ]);
1516
+ });
1517
+ this.OR3([
1459
1518
  // String value
1460
1519
  { ALT: () => this.CONSUME(StringLiteral, { LABEL: "value" }) },
1461
1520
  // Number value
@@ -1464,6 +1523,11 @@ class MathExerciseParser extends chevrotain.CstParser {
1464
1523
  { ALT: () => this.CONSUME2(Identifier, { LABEL: "value" }) },
1465
1524
  // Tuple value: (start, end)
1466
1525
  { ALT: () => this.SUBRULE(this.tuple, { LABEL: "value" }) },
1526
+ // Number-pair list enclosed in braces: {[1, 2], [3, 4]}
1527
+ {
1528
+ GATE: () => this.isBracedNumberPairListAhead(),
1529
+ ALT: () => this.SUBRULE(this.bracedNumberPairList, { LABEL: "value" })
1530
+ },
1467
1531
  // Curly-brace string list: {"a", "b", "c"}
1468
1532
  { ALT: () => this.SUBRULE(this.curlyStringList, { LABEL: "value" }) },
1469
1533
  // Square-bracket number array: [1, 2, 3] — used by VISUAL_FRACTION filled/answer
@@ -1519,8 +1583,52 @@ class MathExerciseParser extends chevrotain.CstParser {
1519
1583
  this.SUBRULE(this.stringList);
1520
1584
  this.CONSUME(RBrace);
1521
1585
  });
1586
+ this.bracedNumberPairList = this.RULE("bracedNumberPairList", () => {
1587
+ this.CONSUME(LBrace);
1588
+ this.MANY(() => {
1589
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1590
+ });
1591
+ this.OPTION(() => {
1592
+ this.SUBRULE(this.numberPair);
1593
+ this.MANY2(() => {
1594
+ this.MANY3(() => {
1595
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1596
+ });
1597
+ this.CONSUME(Comma);
1598
+ this.MANY4(() => {
1599
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1600
+ });
1601
+ this.SUBRULE2(this.numberPair);
1602
+ });
1603
+ });
1604
+ this.MANY5(() => {
1605
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
1606
+ });
1607
+ this.CONSUME(RBrace);
1608
+ });
1609
+ this.numberPair = this.RULE("numberPair", () => {
1610
+ this.CONSUME(LBracket);
1611
+ this.MANY(() => this.CONSUME(Space));
1612
+ this.CONSUME(NumberLiteral, { LABEL: "row" });
1613
+ this.MANY2(() => this.CONSUME2(Space));
1614
+ this.CONSUME(Comma);
1615
+ this.MANY3(() => this.CONSUME3(Space));
1616
+ this.CONSUME2(NumberLiteral, { LABEL: "col" });
1617
+ this.MANY4(() => this.CONSUME4(Space));
1618
+ this.CONSUME(RBracket);
1619
+ });
1522
1620
  this.performSelfAnalysis();
1523
1621
  }
1622
+ /** Distinguish `{[number, number], ...}` and the valid empty form `{}` from string lists. */
1623
+ isBracedNumberPairListAhead() {
1624
+ if (this.LA(1).tokenType !== LBrace) return false;
1625
+ let offset = 2;
1626
+ while (this.LA(offset).tokenType === Space || this.LA(offset).tokenType === Newline) {
1627
+ offset++;
1628
+ }
1629
+ const firstContentToken = this.LA(offset).tokenType;
1630
+ return firstContentToken === LBracket || firstContentToken === RBrace;
1631
+ }
1524
1632
  }
1525
1633
  const parser = new MathExerciseParser();
1526
1634
  function parseOrThrow(schema, data, label) {
@@ -1601,7 +1709,9 @@ const ImageInlineSchema = zod.z.object({
1601
1709
  }),
1602
1710
  alt: zod.z.string(),
1603
1711
  width: zod.z.number().positive().optional(),
1604
- height: zod.z.number().positive().optional()
1712
+ height: zod.z.number().positive().optional(),
1713
+ /** Opt-in: show an expand control that opens the image fullscreen. */
1714
+ fullscreen: zod.z.boolean().optional()
1605
1715
  });
1606
1716
  const VisualFractionInlineSchema = zod.z.object({
1607
1717
  type: zod.z.literal(AST_TYPES.VISUAL_FRACTION),
@@ -1906,10 +2016,8 @@ const NumberLineHighlightSchema = zod.z.object({
1906
2016
  type: zod.z.literal(AST_TYPES.NUMBER_LINE_HIGHLIGHT),
1907
2017
  from: zod.z.number().finite({ message: "HIGHLIGHT: from must be a finite number" }),
1908
2018
  to: zod.z.number().finite({ message: "HIGHLIGHT: to must be a finite number" }),
1909
- color: zod.z.enum(NUMBER_LINE_HIGHLIGHT_COLORS, {
1910
- errorMap: () => ({
1911
- message: `HIGHLIGHT: color must be one of ${NUMBER_LINE_HIGHLIGHT_COLORS.join(", ")}`
1912
- })
2019
+ color: zod.z.string().refine(isNumberLineHighlightColor, {
2020
+ message: highlightColorMessage("HIGHLIGHT")
1913
2021
  }).optional(),
1914
2022
  label: zod.z.string().optional()
1915
2023
  }).refine((d) => d.from < d.to, {
@@ -1928,7 +2036,7 @@ const NumberLineCursorSchema = zod.z.object({
1928
2036
  type: zod.z.literal(AST_TYPES.NUMBER_LINE_CURSOR),
1929
2037
  value: zod.z.number().finite({ message: "CURSOR: value must be a finite number" }).default(0),
1930
2038
  answer: zod.z.number({ required_error: "CURSOR: answer is required" }).finite({ message: "CURSOR: answer must be a finite number" }),
1931
- color: zod.z.string().regex(/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/, {
2039
+ color: zod.z.string().regex(HEX_COLOR_REGEX, {
1932
2040
  message: 'CURSOR: color must be a hex value (e.g. "#3B82F6" or "#F00")'
1933
2041
  }).optional(),
1934
2042
  fillRange: zod.z.boolean().default(false),
@@ -1950,8 +2058,8 @@ const NumberLineArrowSchema = zod.z.object({
1950
2058
  to: zod.z.number().finite({ message: "ARROW: to must be a finite number" }),
1951
2059
  color: zod.z.string().optional(),
1952
2060
  inlines: zod.z.array(InlineNodeSchema)
1953
- }).refine((d) => d.from < d.to, {
1954
- message: "ARROW: from must be less than to"
2061
+ }).refine((d) => d.from !== d.to, {
2062
+ message: "ARROW: from and to must be different"
1955
2063
  });
1956
2064
  function createNumberLineArrow(from, to, inlines, color) {
1957
2065
  return NumberLineArrowSchema.parse({
@@ -2016,7 +2124,9 @@ const NumberLineBlockSchema = zod.z.object({
2016
2124
  }
2017
2125
  }
2018
2126
  for (const arrow of data.arrows ?? []) {
2019
- if (arrow.from < data.min || arrow.to > data.max) {
2127
+ const lo = Math.min(arrow.from, arrow.to);
2128
+ const hi = Math.max(arrow.from, arrow.to);
2129
+ if (lo < data.min || hi > data.max) {
2020
2130
  ctx.addIssue({
2021
2131
  code: zod.z.ZodIssueCode.custom,
2022
2132
  message: `NUMBER_LINE: ARROW range [${arrow.from}, ${arrow.to}] must be within [${data.min}, ${data.max}]`
@@ -2063,6 +2173,135 @@ function createNumberLineBlock(min, max, positions, step, minorTicks, style, lab
2063
2173
  positions
2064
2174
  });
2065
2175
  }
2176
+ const GRID_SYMMETRY_AXES = ["vertical", "horizontal"];
2177
+ const GRID_SYMMETRY_INTERACTIVE_AREAS = ["left", "right", "top", "bottom"];
2178
+ const GRID_SYMMETRY_MAX_ROWS = 20;
2179
+ const GRID_SYMMETRY_MAX_COLS = 20;
2180
+ const GRID_SYMMETRY_MIN_CELL_SIZE = 0.25;
2181
+ const GRID_SYMMETRY_MAX_CELL_SIZE = 2;
2182
+ function isCellInInteractiveArea(cell, rows, cols, area) {
2183
+ const [row, col] = cell;
2184
+ switch (area) {
2185
+ case "left":
2186
+ return col <= Math.floor(cols / 2);
2187
+ case "right":
2188
+ return col > Math.ceil(cols / 2);
2189
+ case "top":
2190
+ return row <= Math.floor(rows / 2);
2191
+ case "bottom":
2192
+ return row > Math.ceil(rows / 2);
2193
+ }
2194
+ }
2195
+ function reflectGridSymmetryCell(cell, rows, cols, axis) {
2196
+ const [row, col] = cell;
2197
+ return axis === "vertical" ? [row, cols + 1 - col] : [rows + 1 - row, col];
2198
+ }
2199
+ function computeGridSymmetryExpectedCells(data) {
2200
+ const seen = /* @__PURE__ */ new Set();
2201
+ const expected = [];
2202
+ for (const cell of data.cells) {
2203
+ if (isCellInInteractiveArea(cell, data.rows, data.cols, data.interactiveArea)) continue;
2204
+ const reflected = reflectGridSymmetryCell(cell, data.rows, data.cols, data.axis);
2205
+ if (!isCellInInteractiveArea(reflected, data.rows, data.cols, data.interactiveArea)) continue;
2206
+ const key2 = gridCellKey(reflected);
2207
+ if (!seen.has(key2)) {
2208
+ seen.add(key2);
2209
+ expected.push(reflected);
2210
+ }
2211
+ }
2212
+ return expected.sort(([ar, ac], [br, bc]) => ar - br || ac - bc);
2213
+ }
2214
+ function computeGridSymmetryCellsToFill(data) {
2215
+ const initial = new Set(data.cells.map(gridCellKey));
2216
+ return computeGridSymmetryExpectedCells(data).filter((cell) => !initial.has(gridCellKey(cell)));
2217
+ }
2218
+ const GridSymmetryBlockSchema = zod.z.object({
2219
+ type: zod.z.literal(AST_TYPES.GRID_SYMMETRY_BLOCK),
2220
+ rows: zod.z.number().int("The number of rows must be a whole number.").positive("The number of rows must be greater than 0.").max(GRID_SYMMETRY_MAX_ROWS, {
2221
+ message: `The number of rows cannot exceed ${GRID_SYMMETRY_MAX_ROWS}.`
2222
+ }),
2223
+ cols: zod.z.number().int("The number of columns must be a whole number.").positive("The number of columns must be greater than 0.").max(GRID_SYMMETRY_MAX_COLS, {
2224
+ message: `The number of columns cannot exceed ${GRID_SYMMETRY_MAX_COLS}.`
2225
+ }),
2226
+ axis: zod.z.enum(GRID_SYMMETRY_AXES),
2227
+ interactiveArea: zod.z.enum(GRID_SYMMETRY_INTERACTIVE_AREAS),
2228
+ cellSize: zod.z.number().finite().min(
2229
+ GRID_SYMMETRY_MIN_CELL_SIZE,
2230
+ `Cell size must be between ${GRID_SYMMETRY_MIN_CELL_SIZE} and ${GRID_SYMMETRY_MAX_CELL_SIZE}.`
2231
+ ).max(
2232
+ GRID_SYMMETRY_MAX_CELL_SIZE,
2233
+ `Cell size must be between ${GRID_SYMMETRY_MIN_CELL_SIZE} and ${GRID_SYMMETRY_MAX_CELL_SIZE}.`
2234
+ ).default(1),
2235
+ cells: zod.z.array(GridCellSchema)
2236
+ }).superRefine((data, ctx) => {
2237
+ const compatible = data.axis === "vertical" ? data.interactiveArea === "left" || data.interactiveArea === "right" : data.interactiveArea === "top" || data.interactiveArea === "bottom";
2238
+ if (!compatible) {
2239
+ ctx.addIssue({
2240
+ code: zod.z.ZodIssueCode.custom,
2241
+ path: ["interactiveArea"],
2242
+ message: data.axis === "vertical" ? 'For a vertical axis, the interactive area must be "left" or "right".' : 'For a horizontal axis, the interactive area must be "top" or "bottom".'
2243
+ });
2244
+ }
2245
+ if (data.axis === "vertical" && data.cols % 2 !== 0) {
2246
+ ctx.addIssue({
2247
+ code: zod.z.ZodIssueCode.custom,
2248
+ path: ["cols"],
2249
+ message: `For a vertical axis, the number of columns must be even. Received: ${data.cols}.`
2250
+ });
2251
+ }
2252
+ if (data.axis === "horizontal" && data.rows % 2 !== 0) {
2253
+ ctx.addIssue({
2254
+ code: zod.z.ZodIssueCode.custom,
2255
+ path: ["rows"],
2256
+ message: `For a horizontal axis, the number of rows must be even. Received: ${data.rows}.`
2257
+ });
2258
+ }
2259
+ const seen = /* @__PURE__ */ new Set();
2260
+ data.cells.forEach((cell, index) => {
2261
+ const [row, col] = cell;
2262
+ if (row > data.rows || col > data.cols) {
2263
+ ctx.addIssue({
2264
+ code: zod.z.ZodIssueCode.custom,
2265
+ path: ["cells", index],
2266
+ message: `Cell [${row},${col}] is outside the ${data.rows} × ${data.cols} grid.`
2267
+ });
2268
+ }
2269
+ const key2 = gridCellKey(cell);
2270
+ if (seen.has(key2)) {
2271
+ ctx.addIssue({
2272
+ code: zod.z.ZodIssueCode.custom,
2273
+ path: ["cells", index],
2274
+ message: `Cell [${row},${col}] is listed more than once.`
2275
+ });
2276
+ }
2277
+ seen.add(key2);
2278
+ });
2279
+ if (!compatible) return;
2280
+ const expected = new Set(computeGridSymmetryExpectedCells(data).map(gridCellKey));
2281
+ data.cells.forEach((cell, index) => {
2282
+ if (isCellInInteractiveArea(cell, data.rows, data.cols, data.interactiveArea) && !expected.has(gridCellKey(cell))) {
2283
+ ctx.addIssue({
2284
+ code: zod.z.ZodIssueCode.custom,
2285
+ path: ["cells", index],
2286
+ message: `Prefilled cell [${cell[0]},${cell[1]}] does not match the expected symmetry.`
2287
+ });
2288
+ }
2289
+ });
2290
+ });
2291
+ function createGridSymmetryBlock(rows, cols, axis, interactiveArea, cells, cellSize = 1) {
2292
+ return GridSymmetryBlockSchema.parse({
2293
+ type: AST_TYPES.GRID_SYMMETRY_BLOCK,
2294
+ rows,
2295
+ cols,
2296
+ axis,
2297
+ interactiveArea,
2298
+ cellSize,
2299
+ cells
2300
+ });
2301
+ }
2302
+ function isGridSymmetryBlock(value) {
2303
+ return GridSymmetryBlockSchema.safeParse(value).success;
2304
+ }
2066
2305
  const TableCellSchema = zod.z.object({
2067
2306
  type: zod.z.literal(AST_TYPES.TABLE_CELL),
2068
2307
  colspan: zod.z.number().int().positive().optional(),
@@ -2562,7 +2801,7 @@ function isMixedValidation(value) {
2562
2801
  function isValidationNode(value) {
2563
2802
  return ValidationNodeSchema.safeParse(value).success;
2564
2803
  }
2565
- const ContentBlockSchema = zod.z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema, MatcherBlockSchema, PairMatcherBlockSchema, VerticalOperationBlockSchema]);
2804
+ const ContentBlockSchema = zod.z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema, MatcherBlockSchema, PairMatcherBlockSchema, VerticalOperationBlockSchema, GridSymmetryBlockSchema]);
2566
2805
  const ExerciseDocumentSchema = zod.z.object({
2567
2806
  type: zod.z.literal(AST_TYPES.EXERCISE_DOCUMENT),
2568
2807
  question: TextBlockSchema.optional(),
@@ -2664,12 +2903,24 @@ class NaturalDSLVisitor extends BaseCstVisitor {
2664
2903
  if (ctx.table) return this.visit(ctx.table);
2665
2904
  if (ctx.radioButtons) return this.visit(ctx.radioButtons);
2666
2905
  if (ctx.numberLine) return this.visit(ctx.numberLine);
2906
+ if (ctx.gridSymmetry) return this.visit(ctx.gridSymmetry);
2667
2907
  if (ctx.pairMatcher) return this.visit(ctx.pairMatcher);
2668
2908
  if (ctx.matcher) return this.visit(ctx.matcher);
2669
2909
  if (ctx.verticalOperation) return this.visit(ctx.verticalOperation);
2670
2910
  if (ctx.line) return this.visit(ctx.line);
2671
2911
  throw new Error("Unknown content block");
2672
2912
  }
2913
+ gridSymmetry(ctx) {
2914
+ const params = this.visit(ctx.parameterList);
2915
+ return createGridSymmetryBlock(
2916
+ Number(params.rows),
2917
+ Number(params.cols),
2918
+ params.axis,
2919
+ params.interactiveArea,
2920
+ params.cells,
2921
+ params.cellSize === void 0 ? 1 : Number(params.cellSize)
2922
+ );
2923
+ }
2673
2924
  radioButtons(ctx) {
2674
2925
  const params = this.visit(ctx.parameterList);
2675
2926
  const name = params.name;
@@ -3108,7 +3359,8 @@ class NaturalDSLVisitor extends BaseCstVisitor {
3108
3359
  const alt = params.alt;
3109
3360
  const width = params.width;
3110
3361
  const height = params.height;
3111
- return createImage(name, src, alt, { width, height });
3362
+ const fullscreen = params.fullscreen !== void 0 ? params.fullscreen === true || params.fullscreen === "true" : void 0;
3363
+ return createImage(name, src, alt, { width, height, fullscreen });
3112
3364
  }
3113
3365
  visualFraction(ctx) {
3114
3366
  const params = this.visit(ctx.parameterList);
@@ -3257,6 +3509,15 @@ class NaturalDSLVisitor extends BaseCstVisitor {
3257
3509
  curlyStringList(ctx) {
3258
3510
  return this.visit(ctx.stringList);
3259
3511
  }
3512
+ bracedNumberPairList(ctx) {
3513
+ return ctx.numberPair ? ctx.numberPair.map((pair) => this.visit(pair)) : [];
3514
+ }
3515
+ numberPair(ctx) {
3516
+ return [
3517
+ parseFloat(ctx.row[0].image),
3518
+ parseFloat(ctx.col[0].image)
3519
+ ];
3520
+ }
3260
3521
  // ═══════════════════════════════════════════════════════════════════════════
3261
3522
  // Helpers
3262
3523
  // ═══════════════════════════════════════════════════════════════════════════
@@ -3330,7 +3591,9 @@ const ImageItemSchema = zod.z.object({
3330
3591
  }),
3331
3592
  alt: zod.z.string(),
3332
3593
  width: zod.z.number().positive().optional(),
3333
- height: zod.z.number().positive().optional()
3594
+ height: zod.z.number().positive().optional(),
3595
+ /** Opt-in: show an expand control that opens the image fullscreen. */
3596
+ fullscreen: zod.z.boolean().optional()
3334
3597
  });
3335
3598
  const SpaceItemSchema = zod.z.object({
3336
3599
  id: zod.z.string(),
@@ -3435,7 +3698,9 @@ const NumberLineHighlightItemSchema = zod.z.object({
3435
3698
  type: zod.z.literal(JSON_TYPES.NUMBER_LINE_HIGHLIGHT),
3436
3699
  from: zod.z.number().finite(),
3437
3700
  to: zod.z.number().finite(),
3438
- color: zod.z.enum(NUMBER_LINE_HIGHLIGHT_COLORS).optional(),
3701
+ color: zod.z.string().refine(isNumberLineHighlightColor, {
3702
+ message: highlightColorMessage("HIGHLIGHT")
3703
+ }).optional(),
3439
3704
  label: zod.z.string().optional()
3440
3705
  });
3441
3706
  const NumberLineCursorItemSchema = zod.z.object({
@@ -3443,7 +3708,7 @@ const NumberLineCursorItemSchema = zod.z.object({
3443
3708
  type: zod.z.literal(JSON_TYPES.NUMBER_LINE_CURSOR),
3444
3709
  value: zod.z.number().finite(),
3445
3710
  answer: zod.z.number().finite(),
3446
- color: zod.z.string().regex(/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/).optional(),
3711
+ color: zod.z.string().regex(HEX_COLOR_REGEX).optional(),
3447
3712
  fillRange: zod.z.boolean(),
3448
3713
  label: zod.z.string().optional()
3449
3714
  });
@@ -3629,6 +3894,34 @@ const VisualFractionJSONSchema = zod.z.object({
3629
3894
  */
3630
3895
  size: zod.z.number().positive().optional()
3631
3896
  }).superRefine(validateVisualFractionCapacity);
3897
+ const GridSymmetryJSONSchema = zod.z.object({
3898
+ type: zod.z.literal(JSON_TYPES.GRID_SYMMETRY),
3899
+ rows: zod.z.number().int().positive(),
3900
+ cols: zod.z.number().int().positive(),
3901
+ axis: zod.z.enum(["vertical", "horizontal"]),
3902
+ interactiveArea: zod.z.enum(["left", "right", "top", "bottom"]),
3903
+ cellSize: zod.z.number().finite().positive().default(1),
3904
+ cells: zod.z.array(GridCellSchema)
3905
+ }).superRefine((data, ctx) => {
3906
+ const result = GridSymmetryBlockSchema.safeParse({
3907
+ type: AST_TYPES.GRID_SYMMETRY_BLOCK,
3908
+ rows: data.rows,
3909
+ cols: data.cols,
3910
+ axis: data.axis,
3911
+ interactiveArea: data.interactiveArea,
3912
+ cellSize: data.cellSize,
3913
+ cells: data.cells
3914
+ });
3915
+ if (!result.success) {
3916
+ for (const issue of result.error.issues) {
3917
+ ctx.addIssue({
3918
+ code: zod.z.ZodIssueCode.custom,
3919
+ path: issue.path,
3920
+ message: issue.message
3921
+ });
3922
+ }
3923
+ }
3924
+ });
3632
3925
  const RulerWidgetSchema = zod.z.object({
3633
3926
  type: zod.z.literal(JSON_TYPES.RULER),
3634
3927
  size: zod.z.number().optional()
@@ -3651,6 +3944,7 @@ const WidgetJSONSchema = zod.z.union([
3651
3944
  MatcherWidgetJSONSchema,
3652
3945
  PairMatcherWidgetJSONSchema,
3653
3946
  VisualFractionJSONSchema,
3947
+ GridSymmetryJSONSchema,
3654
3948
  RulerWidgetSchema,
3655
3949
  ProtractorWidgetSchema,
3656
3950
  SetSquareWidgetSchema
@@ -3666,7 +3960,8 @@ const ValidationJSONSchema = zod.z.object({
3666
3960
  zod.z.string(),
3667
3961
  zod.z.number(),
3668
3962
  zod.z.array(zod.z.array(zod.z.string().min(1)).min(1)).min(1),
3669
- zod.z.array(zod.z.number().int().nonnegative())
3963
+ zod.z.array(zod.z.number().int().nonnegative()),
3964
+ zod.z.array(GridCellSchema)
3670
3965
  ])
3671
3966
  ).optional(),
3672
3967
  expression: zod.z.string().optional(),
@@ -3764,6 +4059,9 @@ function isNumericKeypadJSON(widget) {
3764
4059
  function isFractionJSON(widget) {
3765
4060
  return widget.type === JSON_TYPES.FRACTION;
3766
4061
  }
4062
+ function isGridSymmetryJSON(widget) {
4063
+ return widget.type === JSON_TYPES.GRID_SYMMETRY;
4064
+ }
3767
4065
  function isRulerWidget(widget) {
3768
4066
  return widget.type === JSON_TYPES.RULER;
3769
4067
  }
@@ -9271,6 +9569,7 @@ function toJSON(ast) {
9271
9569
  const idGenerator = new LayoutIdGenerator();
9272
9570
  const widgets = {};
9273
9571
  const answers = {};
9572
+ let gridSymmetryCounter = 0;
9274
9573
  const allInlines = collectAllInlines(ast.lines);
9275
9574
  const inlineDragIds = /* @__PURE__ */ new Map();
9276
9575
  {
@@ -9325,6 +9624,34 @@ function toJSON(ast) {
9325
9624
  });
9326
9625
  } else if (block.type === AST_TYPES.VERTICAL_OPERATION_BLOCK) {
9327
9626
  layout.push(transformVerticalOperation(block, idGenerator));
9627
+ } else if (block.type === AST_TYPES.GRID_SYMMETRY_BLOCK) {
9628
+ const grid = block;
9629
+ const widgetId = `${GRID_SYMMETRY_WIDGET_PREFIX}${gridSymmetryCounter++}`;
9630
+ widgets[widgetId] = {
9631
+ type: JSON_TYPES.GRID_SYMMETRY,
9632
+ rows: grid.rows,
9633
+ cols: grid.cols,
9634
+ axis: grid.axis,
9635
+ interactiveArea: grid.interactiveArea,
9636
+ cellSize: grid.cellSize,
9637
+ cells: grid.cells
9638
+ };
9639
+ const prefilled = grid.cells.filter((cell) => isCellInInteractiveArea(
9640
+ cell,
9641
+ grid.rows,
9642
+ grid.cols,
9643
+ grid.interactiveArea
9644
+ ));
9645
+ answers[widgetId] = [...prefilled, ...computeGridSymmetryCellsToFill(grid)].sort(([ar, ac], [br, bc]) => ar - br || ac - bc);
9646
+ layout.push({
9647
+ id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
9648
+ type: JSON_TYPES.WIDGET_REF,
9649
+ widgetId
9650
+ });
9651
+ layout.push({
9652
+ id: idGenerator.generate(JSON_TYPES.LINEBREAK),
9653
+ type: JSON_TYPES.LINEBREAK
9654
+ });
9328
9655
  } else if (block.type === AST_TYPES.RADIO_OPTIONS) {
9329
9656
  const radioBlock = block;
9330
9657
  layout.push({
@@ -9789,7 +10116,8 @@ function transformInline(inline, idGenerator, inlineDragIds) {
9789
10116
  src: inline.src,
9790
10117
  alt: inline.alt,
9791
10118
  ...inline.width !== void 0 && { width: inline.width },
9792
- ...inline.height !== void 0 && { height: inline.height }
10119
+ ...inline.height !== void 0 && { height: inline.height },
10120
+ ...inline.fullscreen !== void 0 && { fullscreen: inline.fullscreen }
9793
10121
  };
9794
10122
  case AST_TYPES.VISUAL_FRACTION:
9795
10123
  return {
@@ -9921,6 +10249,18 @@ function areVisualFractionCountsEqual(expected, result) {
9921
10249
  if (expected.length !== result.length) return false;
9922
10250
  return lodashExports.isEqual(lodashExports.sortBy(expected), lodashExports.sortBy(result));
9923
10251
  }
10252
+ function isValidGridSymmetryCell(value, rows, cols) {
10253
+ if (!Array.isArray(value) || value.length !== 2) return false;
10254
+ const [row, col] = value;
10255
+ return Number.isInteger(row) && Number.isInteger(col) && row >= 1 && row <= rows && col >= 1 && col <= cols;
10256
+ }
10257
+ function areGridSymmetryCellsEqual(expected, actual, rows, cols) {
10258
+ if (!Array.isArray(actual) || actual.length !== expected.length) return false;
10259
+ if (!actual.every((cell) => isValidGridSymmetryCell(cell, rows, cols))) return false;
10260
+ const actualKeys = new Set(actual.map(gridCellKey));
10261
+ if (actualKeys.size !== actual.length) return false;
10262
+ return expected.every((cell) => actualKeys.has(gridCellKey(cell)));
10263
+ }
9924
10264
  const math$1 = mathjs.create(mathjs.all);
9925
10265
  function collectNumberLines(items) {
9926
10266
  const result = [];
@@ -9994,6 +10334,20 @@ function validateSimple(exercise, userInputs) {
9994
10334
  else allCorrect = false;
9995
10335
  continue;
9996
10336
  }
10337
+ if (widget?.type === JSON_TYPES.GRID_SYMMETRY) {
10338
+ const userValue2 = userInputs[widgetId];
10339
+ const isCorrect2 = areGridSymmetryCellsEqual(
10340
+ expected,
10341
+ userValue2,
10342
+ widget.rows,
10343
+ widget.cols
10344
+ );
10345
+ details[widgetId] = { widgetId, userValue: userValue2, isCorrect: isCorrect2 };
10346
+ totalCount++;
10347
+ if (isCorrect2) correctCount++;
10348
+ else allCorrect = false;
10349
+ continue;
10350
+ }
9997
10351
  totalCount++;
9998
10352
  const userValue = userInputs[widgetId];
9999
10353
  const isFractionLenient = widget?.type === JSON_TYPES.FRACTION && widget.simplified === false;
@@ -10424,9 +10778,19 @@ function getMissingInputs(exercise, userInputs) {
10424
10778
  }
10425
10779
  const inputs = preprocessFractionValues(exercise, userInputs);
10426
10780
  const isEmpty = (v) => v === void 0 || v === null || v === "" || Array.isArray(v) && v.length === 0;
10781
+ const isMissingAnswer = (widgetId) => {
10782
+ const value = inputs[widgetId];
10783
+ const widget = exercise.widgets[widgetId];
10784
+ if (widget?.type === JSON_TYPES.GRID_SYMMETRY) {
10785
+ const expected = exercise.validation?.answers?.[widgetId];
10786
+ const expectedCount = Array.isArray(expected) ? expected.length : 0;
10787
+ return !Array.isArray(value) || value.length < expectedCount;
10788
+ }
10789
+ return !(widgetId in inputs) || isEmpty(value);
10790
+ };
10427
10791
  if (exercise.validation.mode === VALIDATION_MODES.SIMPLE && exercise.validation.answers) {
10428
10792
  for (const widgetId of Object.keys(exercise.validation.answers)) {
10429
- if (!(widgetId in inputs) || isEmpty(inputs[widgetId])) {
10793
+ if (isMissingAnswer(widgetId)) {
10430
10794
  missing.push(widgetId);
10431
10795
  }
10432
10796
  }
@@ -10442,7 +10806,7 @@ function getMissingInputs(exercise, userInputs) {
10442
10806
  ...exercise.validation.variables ?? []
10443
10807
  ]);
10444
10808
  for (const k of keys) {
10445
- if (!(k in inputs) || isEmpty(inputs[k])) missing.push(k);
10809
+ if (isMissingAnswer(k)) missing.push(k);
10446
10810
  }
10447
10811
  }
10448
10812
  return missing;
@@ -15865,6 +16229,94 @@ function getSizeStyle(size, _shape) {
15865
16229
  "--visual-fraction-size": `${size * VISUAL_FRACTION_SVG_SIZE_SCALE}rem`
15866
16230
  };
15867
16231
  }
16232
+ const GridSymmetry = ({
16233
+ widgetId,
16234
+ widget,
16235
+ value,
16236
+ validationStatus = null,
16237
+ onChange
16238
+ }) => {
16239
+ const initialKeys = React.useMemo(() => new Set(widget.cells.map(gridCellKey)), [widget.cells]);
16240
+ const initialInteractiveCells = React.useMemo(
16241
+ () => widget.cells.filter(
16242
+ (cell) => isCellInInteractiveArea(cell, widget.rows, widget.cols, widget.interactiveArea)
16243
+ ),
16244
+ [widget.cells, widget.rows, widget.cols, widget.interactiveArea]
16245
+ );
16246
+ const selectedCells = value ?? initialInteractiveCells;
16247
+ const selectedKeys = React.useMemo(() => new Set(selectedCells.map(gridCellKey)), [selectedCells]);
16248
+ const toggleCell = (cell) => {
16249
+ const key2 = gridCellKey(cell);
16250
+ if (initialKeys.has(key2)) return;
16251
+ const next = selectedKeys.has(key2) ? selectedCells.filter((candidate) => gridCellKey(candidate) !== key2) : [...selectedCells, cell];
16252
+ onChange(widgetId, next);
16253
+ };
16254
+ const statusClass = validationStatus ? ` grid-symmetry--${validationStatus}` : "";
16255
+ const cells = [];
16256
+ for (let row = 1; row <= widget.rows; row++) {
16257
+ for (let col = 1; col <= widget.cols; col++) {
16258
+ const cell = [row, col];
16259
+ const key2 = gridCellKey(cell);
16260
+ const interactive = isCellInInteractiveArea(
16261
+ cell,
16262
+ widget.rows,
16263
+ widget.cols,
16264
+ widget.interactiveArea
16265
+ );
16266
+ const initial = initialKeys.has(key2);
16267
+ const selected = selectedKeys.has(key2);
16268
+ const locked = initial || !interactive;
16269
+ const roleClass = initial ? interactive ? " grid-symmetry__cell--prefilled" : " grid-symmetry__cell--reference" : selected ? " grid-symmetry__cell--answer" : "";
16270
+ const className = `grid-symmetry__cell${interactive ? " grid-symmetry__cell--interactive" : ""}${locked ? "" : " grid-symmetry__cell--editable"}${selected || initial ? " grid-symmetry__cell--selected" : ""}${roleClass}`;
16271
+ const label = `Row ${row}, column ${col}`;
16272
+ cells.push(
16273
+ locked ? /* @__PURE__ */ jsxRuntime.jsx(
16274
+ "span",
16275
+ {
16276
+ className,
16277
+ role: initial ? "img" : void 0,
16278
+ "aria-label": initial ? `${label}, filled` : void 0,
16279
+ "aria-hidden": initial ? void 0 : true
16280
+ },
16281
+ key2
16282
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
16283
+ "button",
16284
+ {
16285
+ type: "button",
16286
+ className,
16287
+ "aria-label": label,
16288
+ "aria-pressed": selected,
16289
+ onClick: () => toggleCell(cell)
16290
+ },
16291
+ key2
16292
+ )
16293
+ );
16294
+ }
16295
+ }
16296
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: `grid-symmetry${statusClass}`, "data-testid": "grid-symmetry", children: /* @__PURE__ */ jsxRuntime.jsxs(
16297
+ "div",
16298
+ {
16299
+ className: "grid-symmetry__grid",
16300
+ style: {
16301
+ "--grid-symmetry-cell-size": `calc(48px * ${widget.cellSize})`,
16302
+ gridTemplateColumns: `repeat(${widget.cols}, var(--grid-symmetry-cell-size))`,
16303
+ gridTemplateRows: `repeat(${widget.rows}, var(--grid-symmetry-cell-size))`
16304
+ },
16305
+ role: "group",
16306
+ "aria-label": `Symmetry grid, ${widget.rows} rows by ${widget.cols} columns`,
16307
+ children: [
16308
+ cells,
16309
+ /* @__PURE__ */ jsxRuntime.jsx(
16310
+ "span",
16311
+ {
16312
+ className: `grid-symmetry__axis grid-symmetry__axis--${widget.axis}`,
16313
+ "aria-hidden": "true"
16314
+ }
16315
+ )
16316
+ ]
16317
+ }
16318
+ ) });
16319
+ };
15868
16320
  const RadioOptions = ({
15869
16321
  name,
15870
16322
  options,
@@ -16513,6 +16965,9 @@ function isPairMatcherWidget(widget) {
16513
16965
  function isVisualFractionWidget(widget) {
16514
16966
  return widget.type === "visual-fraction";
16515
16967
  }
16968
+ function isGridSymmetryWidget(widget) {
16969
+ return widget.type === "grid-symmetry";
16970
+ }
16516
16971
  const WidgetRenderer = ({
16517
16972
  widget,
16518
16973
  widgetId,
@@ -16657,8 +17112,243 @@ const WidgetRenderer = ({
16657
17112
  }
16658
17113
  );
16659
17114
  }
17115
+ if (isGridSymmetryWidget(widget)) {
17116
+ const cells = Array.isArray(value) && value.every(
17117
+ (cell) => Array.isArray(cell) && cell.length === 2 && cell.every(Number.isInteger)
17118
+ ) ? value : void 0;
17119
+ return /* @__PURE__ */ jsxRuntime.jsx(
17120
+ GridSymmetry,
17121
+ {
17122
+ widgetId,
17123
+ widget,
17124
+ value: cells,
17125
+ validationStatus,
17126
+ onChange: (id, nextValue) => onChange(id, nextValue)
17127
+ }
17128
+ );
17129
+ }
16660
17130
  return null;
16661
17131
  };
17132
+ const ExpandDiamondIcon = () => {
17133
+ const gradientId = React.useId();
17134
+ return /* @__PURE__ */ jsxRuntime.jsxs(
17135
+ "svg",
17136
+ {
17137
+ className: "image-zoom-icon",
17138
+ viewBox: "0 0 60 60",
17139
+ fill: "none",
17140
+ xmlns: "http://www.w3.org/2000/svg",
17141
+ "aria-hidden": "true",
17142
+ focusable: "false",
17143
+ children: [
17144
+ /* @__PURE__ */ jsxRuntime.jsx(
17145
+ "rect",
17146
+ {
17147
+ y: "-1.41531",
17148
+ width: "31.6754",
17149
+ height: "31.6754",
17150
+ rx: "7",
17151
+ transform: "matrix(0.706556 0.707657 -0.706556 0.707657 29.4526 10.0862)",
17152
+ fill: "white"
17153
+ }
17154
+ ),
17155
+ /* @__PURE__ */ jsxRuntime.jsx(
17156
+ "rect",
17157
+ {
17158
+ y: "-1.41531",
17159
+ width: "31.6754",
17160
+ height: "31.6754",
17161
+ rx: "7",
17162
+ transform: "matrix(0.706556 0.707657 -0.706556 0.707657 29.4526 10.0862)",
17163
+ stroke: "#BDAFFE",
17164
+ strokeWidth: "2"
17165
+ }
17166
+ ),
17167
+ /* @__PURE__ */ jsxRuntime.jsx(
17168
+ "rect",
17169
+ {
17170
+ y: "-1.41531",
17171
+ width: "31.6754",
17172
+ height: "31.6754",
17173
+ rx: "7",
17174
+ transform: "matrix(0.706556 0.707657 -0.706556 0.707657 29.4526 10.0862)",
17175
+ stroke: `url(#${gradientId})`,
17176
+ strokeOpacity: "0.3",
17177
+ strokeWidth: "2"
17178
+ }
17179
+ ),
17180
+ /* @__PURE__ */ jsxRuntime.jsxs(
17181
+ "g",
17182
+ {
17183
+ stroke: "currentColor",
17184
+ strokeWidth: "2.5",
17185
+ strokeLinecap: "round",
17186
+ strokeLinejoin: "round",
17187
+ children: [
17188
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M22.45 27 L22.45 23.5 L25.95 23.5" }),
17189
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M34.95 23.5 L38.45 23.5 L38.45 27" }),
17190
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M38.45 36 L38.45 39.5 L34.95 39.5" }),
17191
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M25.95 39.5 L22.45 39.5 L22.45 36" })
17192
+ ]
17193
+ }
17194
+ ),
17195
+ /* @__PURE__ */ jsxRuntime.jsx("defs", { children: /* @__PURE__ */ jsxRuntime.jsxs(
17196
+ "linearGradient",
17197
+ {
17198
+ id: gradientId,
17199
+ x1: "3.84406",
17200
+ y1: "3.97407",
17201
+ x2: "24.7079",
17202
+ y2: "24.8379",
17203
+ gradientUnits: "userSpaceOnUse",
17204
+ children: [
17205
+ /* @__PURE__ */ jsxRuntime.jsx("stop", { stopColor: "#FAFAFA" }),
17206
+ /* @__PURE__ */ jsxRuntime.jsx("stop", { offset: "1", stopColor: "#8A4899" })
17207
+ ]
17208
+ }
17209
+ ) })
17210
+ ]
17211
+ }
17212
+ );
17213
+ };
17214
+ const CloseIcon = () => /* @__PURE__ */ jsxRuntime.jsxs(
17215
+ "svg",
17216
+ {
17217
+ className: "image-lightbox-close-icon",
17218
+ viewBox: "0 0 24 24",
17219
+ fill: "none",
17220
+ xmlns: "http://www.w3.org/2000/svg",
17221
+ "aria-hidden": "true",
17222
+ focusable: "false",
17223
+ children: [
17224
+ /* @__PURE__ */ jsxRuntime.jsx(
17225
+ "path",
17226
+ {
17227
+ d: "M18 6L6 18",
17228
+ stroke: "currentColor",
17229
+ strokeWidth: "2",
17230
+ strokeLinecap: "round",
17231
+ strokeLinejoin: "round"
17232
+ }
17233
+ ),
17234
+ /* @__PURE__ */ jsxRuntime.jsx(
17235
+ "path",
17236
+ {
17237
+ d: "M6 6L18 18",
17238
+ stroke: "currentColor",
17239
+ strokeWidth: "2",
17240
+ strokeLinecap: "round",
17241
+ strokeLinejoin: "round"
17242
+ }
17243
+ )
17244
+ ]
17245
+ }
17246
+ );
17247
+ const ImageItem = ({
17248
+ src,
17249
+ alt,
17250
+ width,
17251
+ height,
17252
+ fullscreen = false
17253
+ }) => {
17254
+ const [isOpen, setIsOpen] = React.useState(false);
17255
+ const triggerRef = React.useRef(null);
17256
+ const closeRef = React.useRef(null);
17257
+ const open = React.useCallback(() => setIsOpen(true), []);
17258
+ const close = React.useCallback(() => setIsOpen(false), []);
17259
+ const isLightboxOpen = isOpen && fullscreen;
17260
+ React.useEffect(() => {
17261
+ if (!isLightboxOpen) return;
17262
+ const onKeyDown = (e) => {
17263
+ if (e.key === "Escape") {
17264
+ e.stopPropagation();
17265
+ close();
17266
+ }
17267
+ };
17268
+ document.addEventListener("keydown", onKeyDown);
17269
+ return () => document.removeEventListener("keydown", onKeyDown);
17270
+ }, [isLightboxOpen, close]);
17271
+ React.useEffect(() => {
17272
+ if (!isLightboxOpen) return;
17273
+ const previous = document.body.style.overflow;
17274
+ document.body.style.overflow = "hidden";
17275
+ return () => {
17276
+ document.body.style.overflow = previous;
17277
+ };
17278
+ }, [isLightboxOpen]);
17279
+ const wasOpen = React.useRef(false);
17280
+ React.useEffect(() => {
17281
+ if (isLightboxOpen) {
17282
+ closeRef.current?.focus();
17283
+ } else if (wasOpen.current) {
17284
+ triggerRef.current?.focus({ preventScroll: true });
17285
+ }
17286
+ wasOpen.current = isLightboxOpen;
17287
+ }, [isLightboxOpen]);
17288
+ const overlay = isLightboxOpen && typeof document !== "undefined" ? reactDom.createPortal(
17289
+ /* @__PURE__ */ jsxRuntime.jsxs(
17290
+ "div",
17291
+ {
17292
+ className: "image-lightbox",
17293
+ role: "dialog",
17294
+ "aria-modal": "true",
17295
+ "aria-label": alt || "Image",
17296
+ onClick: close,
17297
+ children: [
17298
+ /* @__PURE__ */ jsxRuntime.jsx(
17299
+ "button",
17300
+ {
17301
+ ref: closeRef,
17302
+ type: "button",
17303
+ className: "image-lightbox-close",
17304
+ "aria-label": "Close fullscreen image",
17305
+ onClick: close,
17306
+ children: /* @__PURE__ */ jsxRuntime.jsx(CloseIcon, {})
17307
+ }
17308
+ ),
17309
+ /* @__PURE__ */ jsxRuntime.jsx(
17310
+ "img",
17311
+ {
17312
+ className: "image-lightbox-img",
17313
+ src,
17314
+ alt,
17315
+ onClick: (e) => e.stopPropagation()
17316
+ }
17317
+ )
17318
+ ]
17319
+ }
17320
+ ),
17321
+ document.body
17322
+ ) : null;
17323
+ const img = /* @__PURE__ */ jsxRuntime.jsx(
17324
+ "img",
17325
+ {
17326
+ className: `image-item text-item ${fullscreen ? "image-item-zoomable" : ""}`,
17327
+ src,
17328
+ alt,
17329
+ ...width !== void 0 && { width },
17330
+ ...height !== void 0 && { height },
17331
+ loading: "lazy",
17332
+ ...fullscreen && { onClick: open }
17333
+ }
17334
+ );
17335
+ if (!fullscreen) return img;
17336
+ return /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "image-item-wrapper", children: [
17337
+ img,
17338
+ /* @__PURE__ */ jsxRuntime.jsx(
17339
+ "button",
17340
+ {
17341
+ ref: triggerRef,
17342
+ type: "button",
17343
+ className: "image-zoom-btn",
17344
+ "aria-label": alt ? `View "${alt}" fullscreen` : "View image fullscreen",
17345
+ onClick: open,
17346
+ children: /* @__PURE__ */ jsxRuntime.jsx(ExpandDiamondIcon, {})
17347
+ }
17348
+ ),
17349
+ overlay
17350
+ ] });
17351
+ };
16662
17352
  const EPSILON = 10 ** -INTERNAL_PRECISION;
16663
17353
  const cleanFloat = (n) => parseFloat(n.toFixed(INTERNAL_PRECISION));
16664
17354
  const roundTick = (n) => parseFloat(n.toFixed(DISPLAY_PRECISION));
@@ -16712,7 +17402,7 @@ const getPositionWidgetId = (content) => {
16712
17402
  }
16713
17403
  return null;
16714
17404
  };
16715
- const toHighlightColor = (color) => `var(--nl-highlight-${color})`;
17405
+ const toHighlightColor = (color) => color.startsWith("#") ? color : `var(--nl-highlight-${color})`;
16716
17406
  const getBandGeometry = (h, min, max) => {
16717
17407
  const leftPct = toPercent(Math.min(h.from, h.to), min, max);
16718
17408
  const rightPct = toPercent(Math.max(h.from, h.to), min, max);
@@ -17409,24 +18099,27 @@ function getArrowArcLabelTop(widthPct) {
17409
18099
  const arcTop = svgTop + ARROW_ARC_BASE_Y - getArrowArcLift(widthPct);
17410
18100
  return Math.max(0, arcTop - ARROW_ARC_LABEL_OFFSET);
17411
18101
  }
17412
- function getArrowArcPaths(widthPct, svgWidthPx = ARROW_ARC_VIEWBOX_WIDTH) {
17413
- const endX = getArrowArcSvgWidth(svgWidthPx);
18102
+ function getArrowArcPaths(widthPct, svgWidthPx = ARROW_ARC_VIEWBOX_WIDTH, reversed = false) {
18103
+ const width = getArrowArcSvgWidth(svgWidthPx);
18104
+ const startX = reversed ? width : 0;
18105
+ const tipX = reversed ? 0 : width;
18106
+ const sign = reversed ? -1 : 1;
17414
18107
  const controlInset = Math.min(svgWidthPx * 0.18, 48);
17415
18108
  const lift = getArrowArcLift(widthPct);
17416
18109
  const controlY = fmt(ARROW_ARC_BASE_Y - lift);
17417
- const tangentAngle = Math.atan2(ARROW_ARC_BASE_Y - controlY, endX - (endX - controlInset));
17418
- const tx = Math.cos(tangentAngle);
18110
+ const tangentAngle = Math.atan2(ARROW_ARC_BASE_Y - controlY, controlInset);
18111
+ const tx = Math.cos(tangentAngle) * sign;
17419
18112
  const ty = Math.sin(tangentAngle);
17420
- const shaftEndX = fmt(endX - tx * ARROW_ARC_HEAD_LENGTH);
18113
+ const shaftEndX = fmt(tipX - tx * ARROW_ARC_HEAD_LENGTH);
17421
18114
  const shaftEndY = fmt(ARROW_ARC_BASE_Y - ty * ARROW_ARC_HEAD_LENGTH);
17422
18115
  const endControlX = fmt(shaftEndX - tx * controlInset);
17423
18116
  const endControlY = fmt(shaftEndY - ty * controlInset);
17424
- const arcPath = `M 0 ${ARROW_ARC_BASE_Y} C ${fmt(controlInset)} ${controlY} ${endControlX} ${endControlY} ${shaftEndX} ${shaftEndY}`;
18117
+ const arcPath = `M ${fmt(startX)} ${ARROW_ARC_BASE_Y} C ${fmt(startX + sign * controlInset)} ${controlY} ${endControlX} ${endControlY} ${shaftEndX} ${shaftEndY}`;
17425
18118
  const nx = -ty;
17426
18119
  const ny = tx;
17427
- const baseX = endX - tx * ARROW_ARC_HEAD_LENGTH;
18120
+ const baseX = tipX - tx * ARROW_ARC_HEAD_LENGTH;
17428
18121
  const baseY = ARROW_ARC_BASE_Y - ty * ARROW_ARC_HEAD_LENGTH;
17429
- const headPath = `M ${fmt(endX)} ${ARROW_ARC_BASE_Y} L ${fmt(baseX + nx * ARROW_ARC_HEAD_HALF_WIDTH)} ${fmt(baseY + ny * ARROW_ARC_HEAD_HALF_WIDTH)} L ${fmt(baseX - nx * ARROW_ARC_HEAD_HALF_WIDTH)} ${fmt(baseY - ny * ARROW_ARC_HEAD_HALF_WIDTH)} Z`;
18122
+ const headPath = `M ${fmt(tipX)} ${ARROW_ARC_BASE_Y} L ${fmt(baseX + nx * ARROW_ARC_HEAD_HALF_WIDTH)} ${fmt(baseY + ny * ARROW_ARC_HEAD_HALF_WIDTH)} L ${fmt(baseX - nx * ARROW_ARC_HEAD_HALF_WIDTH)} ${fmt(baseY - ny * ARROW_ARC_HEAD_HALF_WIDTH)} Z`;
17430
18123
  return { arcPath, headPath };
17431
18124
  }
17432
18125
  function getArrowArcLayouts(arrows, min, max) {
@@ -17437,9 +18130,10 @@ function getArrowArcLayouts(arrows, min, max) {
17437
18130
  return {
17438
18131
  arrow,
17439
18132
  index,
17440
- leftPct: fromPct,
17441
- rightPct: toPct,
17442
- widthPct: toPct - fromPct,
18133
+ leftPct: Math.min(fromPct, toPct),
18134
+ rightPct: Math.max(fromPct, toPct),
18135
+ widthPct: Math.abs(toPct - fromPct),
18136
+ reversed: toPct < fromPct,
17443
18137
  level: 0
17444
18138
  };
17445
18139
  }).filter((layout) => layout.widthPct > 0).sort((a, b) => a.leftPct - b.leftPct || a.rightPct - b.rightPct || a.index - b.index);
@@ -17476,7 +18170,7 @@ function useElementWidth() {
17476
18170
  return [ref, width];
17477
18171
  }
17478
18172
  const ArrowArc = ({
17479
- layout: { arrow, leftPct, widthPct, level },
18173
+ layout: { arrow, leftPct, widthPct, reversed, level },
17480
18174
  widgets,
17481
18175
  values,
17482
18176
  droppedInputs,
@@ -17488,8 +18182,8 @@ const ArrowArc = ({
17488
18182
  const hasLabel = arrow.content.length > 0;
17489
18183
  const svgWidthPx = getArrowArcSvgWidth(renderedWidthPx);
17490
18184
  const { arcPath, headPath } = React.useMemo(
17491
- () => getArrowArcPaths(widthPct, svgWidthPx),
17492
- [widthPct, svgWidthPx]
18185
+ () => getArrowArcPaths(widthPct, svgWidthPx, reversed),
18186
+ [widthPct, svgWidthPx, reversed]
17493
18187
  );
17494
18188
  return /* @__PURE__ */ jsxRuntime.jsxs(
17495
18189
  "div",
@@ -18245,14 +18939,13 @@ const LayoutRenderer = ({
18245
18939
  }
18246
18940
  case JSON_TYPES.IMAGE:
18247
18941
  return /* @__PURE__ */ jsxRuntime.jsx(
18248
- "img",
18942
+ ImageItem,
18249
18943
  {
18250
- className: "image-item text-item",
18251
18944
  src: item.src,
18252
18945
  alt: item.alt,
18253
- ...item.width !== void 0 && { width: item.width },
18254
- ...item.height !== void 0 && { height: item.height },
18255
- loading: "lazy"
18946
+ width: item.width,
18947
+ height: item.height,
18948
+ fullscreen: item.fullscreen
18256
18949
  }
18257
18950
  );
18258
18951
  case JSON_TYPES.WIDGET_REF: {
@@ -18498,6 +19191,14 @@ function buildInitialValues(json) {
18498
19191
  const initial = {};
18499
19192
  for (const [id, widget] of Object.entries(json.widgets)) {
18500
19193
  if (widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.PAIR_MATCHER) initial[id] = [];
19194
+ if (widget.type === JSON_TYPES.GRID_SYMMETRY) {
19195
+ initial[id] = widget.cells.filter((cell) => isCellInInteractiveArea(
19196
+ cell,
19197
+ widget.rows,
19198
+ widget.cols,
19199
+ widget.interactiveArea
19200
+ ));
19201
+ }
18501
19202
  }
18502
19203
  return initial;
18503
19204
  }
@@ -18926,6 +19627,15 @@ exports.ExpressionValidationSchema = ExpressionValidationSchema;
18926
19627
  exports.FractionInlineSchema = FractionInlineSchema;
18927
19628
  exports.FractionJSONSchema = FractionJSONSchema;
18928
19629
  exports.FreeTextInlineSchema = FreeTextInlineSchema;
19630
+ exports.GRID_SYMMETRY_AXES = GRID_SYMMETRY_AXES;
19631
+ exports.GRID_SYMMETRY_INTERACTIVE_AREAS = GRID_SYMMETRY_INTERACTIVE_AREAS;
19632
+ exports.GRID_SYMMETRY_MAX_CELL_SIZE = GRID_SYMMETRY_MAX_CELL_SIZE;
19633
+ exports.GRID_SYMMETRY_MAX_COLS = GRID_SYMMETRY_MAX_COLS;
19634
+ exports.GRID_SYMMETRY_MAX_ROWS = GRID_SYMMETRY_MAX_ROWS;
19635
+ exports.GRID_SYMMETRY_MIN_CELL_SIZE = GRID_SYMMETRY_MIN_CELL_SIZE;
19636
+ exports.GridCellSchema = GridCellSchema;
19637
+ exports.GridSymmetryBlockSchema = GridSymmetryBlockSchema;
19638
+ exports.GridSymmetryJSONSchema = GridSymmetryJSONSchema;
18929
19639
  exports.GroupItemSchema = GroupItemSchema;
18930
19640
  exports.HighlightedTextInlineSchema = HighlightedTextInlineSchema;
18931
19641
  exports.HighlightedTextSchema = HighlightedTextSchema;
@@ -19012,6 +19722,8 @@ exports.WidgetRefSchema = WidgetRefSchema;
19012
19722
  exports.WidgetRenderer = WidgetRenderer;
19013
19723
  exports.compile = compile;
19014
19724
  exports.compileWithAST = compileWithAST;
19725
+ exports.computeGridSymmetryCellsToFill = computeGridSymmetryCellsToFill;
19726
+ exports.computeGridSymmetryExpectedCells = computeGridSymmetryExpectedCells;
19015
19727
  exports.createBoldText = createBoldText;
19016
19728
  exports.createColorText = createColorText;
19017
19729
  exports.createDisplayClock = createDisplayClock;
@@ -19020,6 +19732,7 @@ exports.createExerciseDocument = createExerciseDocument;
19020
19732
  exports.createExpressionValidation = createExpressionValidation;
19021
19733
  exports.createFraction = createFraction;
19022
19734
  exports.createFreeText = createFreeText;
19735
+ exports.createGridSymmetryBlock = createGridSymmetryBlock;
19023
19736
  exports.createHighlightedText = createHighlightedText;
19024
19737
  exports.createImage = createImage;
19025
19738
  exports.createInlineDragOption = createInlineDragOption;
@@ -19058,6 +19771,7 @@ exports.getAnswerableInputs = getAnswerableInputs;
19058
19771
  exports.getFractionKey = getFractionKey;
19059
19772
  exports.getMissingInputs = getMissingInputs;
19060
19773
  exports.isBoldText = isBoldText;
19774
+ exports.isCellInInteractiveArea = isCellInInteractiveArea;
19061
19775
  exports.isClock = isClock;
19062
19776
  exports.isClockItem = isClockItem;
19063
19777
  exports.isColorText = isColorText;
@@ -19069,6 +19783,8 @@ exports.isFraction = isFraction;
19069
19783
  exports.isFractionJSON = isFractionJSON;
19070
19784
  exports.isFractionKey = isFractionKey;
19071
19785
  exports.isFreeText = isFreeText;
19786
+ exports.isGridSymmetryBlock = isGridSymmetryBlock;
19787
+ exports.isGridSymmetryJSON = isGridSymmetryJSON;
19072
19788
  exports.isGroupItem = isGroupItem;
19073
19789
  exports.isHighlightedText = isHighlightedText;
19074
19790
  exports.isImage = isImage;
@@ -19121,6 +19837,7 @@ exports.normalizeVopOperator = normalizeVopOperator;
19121
19837
  exports.parse = parse;
19122
19838
  exports.parseFractionKey = parseFractionKey;
19123
19839
  exports.parseSafe = parseSafe;
19840
+ exports.reflectGridSymmetryCell = reflectGridSymmetryCell;
19124
19841
  exports.renderExercise = renderExercise;
19125
19842
  exports.toAST = toAST;
19126
19843
  exports.toJSON = toJSON;