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