react-chess-puzzle-kit 1.0.0 → 1.0.1

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.
Files changed (66) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +331 -331
  3. package/dist/features/analysis/analysisContext.d.ts +1 -1
  4. package/dist/features/board/LineBoardWithControls.d.ts +3 -1
  5. package/dist/features/board/PuzzleBoard.d.ts +6 -1
  6. package/dist/features/board/PuzzleBoardWithControls.d.ts +63 -6
  7. package/dist/features/board/PuzzlePlaySurface.d.ts +27 -3
  8. package/dist/features/board/puzzleBoardLayout.d.ts +17 -4
  9. package/dist/features/board/useStackPuzzleControlsBelow.d.ts +2 -0
  10. package/dist/features/position/Position.d.ts +18 -2
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.esm.js +416 -96
  13. package/dist/index.js +416 -94
  14. package/package.json +87 -87
  15. package/dist/features/analysis/AnalysisBoard.d.ts +0 -18
  16. package/dist/features/analysis/AnalysisBoardCore.d.ts +0 -28
  17. package/dist/features/analysis/AnalysisBoardLayout.d.ts +0 -12
  18. package/dist/features/analysis/AnalysisChessboardView.d.ts +0 -5
  19. package/dist/features/analysis/AnalysisPosition.d.ts +0 -53
  20. package/dist/features/analysis/AnalysisTrigger.d.ts +0 -6
  21. package/dist/features/analysis/DefaultAnalysisContainer.d.ts +0 -3
  22. package/dist/features/analysis/DefaultAnalysisSidebar.d.ts +0 -2
  23. package/dist/features/analysis/analysisBoardHighlightColors.d.ts +0 -12
  24. package/dist/features/analysis/analysisLayout.d.ts +0 -5
  25. package/dist/features/analysis/core/AnalysisBoardCore.d.ts +0 -28
  26. package/dist/features/analysis/core/AnalysisChessboardView.d.ts +0 -5
  27. package/dist/features/analysis/core/AnalysisErrorBoundary.d.ts +0 -14
  28. package/dist/features/analysis/core/AnalysisPosition.d.ts +0 -54
  29. package/dist/features/analysis/core/analysisContext.d.ts +0 -13
  30. package/dist/features/analysis/core/analysisLayout.d.ts +0 -5
  31. package/dist/features/analysis/core/analysisLayoutConfig.d.ts +0 -6
  32. package/dist/features/analysis/core/index.d.ts +0 -9
  33. package/dist/features/analysis/core/renderProps.d.ts +0 -39
  34. package/dist/features/analysis/core/useAnalysisBoardModel.d.ts +0 -36
  35. package/dist/features/analysis/core/usePuzzleAnalysis.d.ts +0 -10
  36. package/dist/features/analysis/defaults/AnalysisBoard.d.ts +0 -16
  37. package/dist/features/analysis/defaults/AnalysisBoardLayout.d.ts +0 -14
  38. package/dist/features/analysis/defaults/DefaultAnalysisContainer.d.ts +0 -4
  39. package/dist/features/analysis/defaults/DefaultAnalysisSidebar.d.ts +0 -3
  40. package/dist/features/analysis/defaults/analysisLayout.d.ts +0 -3
  41. package/dist/features/analysis/defaults/analysisModalStyles.d.ts +0 -7
  42. package/dist/features/analysis/defaults/analysisSidebarColors.d.ts +0 -37
  43. package/dist/features/analysis/defaults/analysisSidebarRowStyle.d.ts +0 -8
  44. package/dist/features/analysis/defaults/index.d.ts +0 -8
  45. package/dist/features/analysis/renderProps.d.ts +0 -39
  46. package/dist/features/analysis/useAnalysisBoardModel.d.ts +0 -33
  47. package/dist/features/board/HighlightChessboard.d.ts +0 -2
  48. package/dist/features/board/boardSquareHighlightColors.d.ts +0 -1
  49. package/dist/features/board/chessboardTheme.d.ts +0 -2
  50. package/dist/features/engine/EngineEvaluationPanel.d.ts +0 -8
  51. package/dist/features/engine/StockfishBrowserEngine.d.ts +0 -1
  52. package/dist/features/engine/formatEvaluation.d.ts +0 -1
  53. package/dist/features/engine/index.d.ts +0 -1
  54. package/dist/features/engine/isAnalyzableFen.d.ts +0 -1
  55. package/dist/features/engine/parseUciInfo.d.ts +0 -1
  56. package/dist/features/engine/stockfishUrls.d.ts +0 -1
  57. package/dist/features/engine/types.d.ts +0 -1
  58. package/dist/features/engine/useAnalysisEngine.d.ts +0 -1
  59. package/dist/features/theme/ThemeContext.d.ts +0 -26
  60. package/dist/features/theme/ThemeProvider.d.ts +0 -7
  61. package/dist/features/theme/squareHighlightColors.d.ts +0 -50
  62. package/dist/stories/AnalysisBoard.stories.d.ts +0 -9
  63. package/dist/stories/HighlightChessboard.stories.d.ts +0 -9
  64. package/dist/stories/analysisFixtures.d.ts +0 -4
  65. package/dist/stories/regressions/StockfishAnalysisRegressions.stories.d.ts +0 -7
  66. package/dist/stories/regressions/regressionAnalysisContext.d.ts +0 -5
package/dist/index.esm.js CHANGED
@@ -1,7 +1,6 @@
1
- import { useState, useEffect, useRef, useCallback } from 'react';
1
+ import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
2
2
  import { jsx, jsxs } from 'react/jsx-runtime';
3
- import { ChessboardDnDProvider } from 'react-chessboard';
4
- import { HighlightChessboard, ThemeProvider, AnalysisErrorBoundary, AnalysisBoardCore, AnalysisBoardLayout, AnalysisBoard, DEFAULT_ANALYSIS_LAYOUT, boardSquareHighlightColors, analysisBoardHighlightColors } from 'react-chess-core';
3
+ import { useBoardRevision, useMissBoard, ChessboardDnDProvider, HighlightChessboard, uciFromDrop, ThemeProvider, AnalysisErrorBoundary, AnalysisBoardCore, AnalysisBoardLayout, AnalysisBoard, DEFAULT_ANALYSIS_LAYOUT, evaluateExpectedMoveDrop, boardSquareHighlightColors, analysisBoardHighlightColors } from 'react-chess-core';
5
4
  export { DEFAULT_ANALYSIS_LAYOUT } from 'react-chess-core';
6
5
  import { Chess } from 'chess.js';
7
6
 
@@ -22,18 +21,17 @@ const buildAnalysisContext = (position) => {
22
21
  boardOrientation: position.getPlayerColor(),
23
22
  };
24
23
  };
25
- const isAnalysisAvailable = (position, resultStatus) => {
24
+ const isAnalysisAvailable = (position, _resultStatus) => {
26
25
  if (!position) {
27
26
  return false;
28
27
  }
29
- return (buildAnalysisContext(position).solutionMoves.length > 0 &&
30
- resultStatus !== 'none');
28
+ return buildAnalysisContext(position).solutionMoves.length > 0;
31
29
  };
32
30
 
33
31
  const usePuzzleAnalysis = (position, resultStatus, puzzleNum) => {
34
32
  const [isOpen, setIsOpen] = useState(false);
35
33
  const [snapshot, setSnapshot] = useState(null);
36
- const canOpen = isAnalysisAvailable(position, resultStatus);
34
+ const canOpen = isAnalysisAvailable(position);
37
35
  useEffect(() => {
38
36
  setIsOpen(false);
39
37
  setSnapshot(null);
@@ -58,68 +56,209 @@ const usePuzzleAnalysis = (position, resultStatus, puzzleNum) => {
58
56
  };
59
57
 
60
58
  const EMPTY_BOARD_FEN = '8/8/8/8/8/8/8/8 w - - 0 1';
59
+ const DEFAULT_ANSWER_ARROW_COLOR = '#42a5f5';
61
60
  /**
62
- * Single mounted board for puzzle play. Shows an empty board while the next
63
- * position loads so the layout and controls do not flicker between cards.
61
+ * Single mounted board for puzzle play. Keeps the prior board (and orientation)
62
+ * visible while the next position loads so layout and perspective do not flicker.
64
63
  */
65
- const PuzzlePlaySurface = ({ position, onFeedback, incInteractionNum, boardWidth, revealAnswerOnIncorrect = false, }) => {
66
- var _a, _b, _c, _d, _e;
64
+ const PuzzlePlaySurface = ({ position, onFeedback, incInteractionNum, boardWidth, onResumeCorrect, revealAnswerOnIncorrect = false, showAnswerArrowOnIncorrect = false, allowRetryOnIncorrect = true, showRefutationOnIncorrect = false, autoShowWrongMoves = true, refutationEngine, answerArrowColor = DEFAULT_ANSWER_ARROW_COLOR, positionLocked = false, onMissFeedbackChange, }) => {
65
+ var _a, _b, _c, _d, _e, _f, _g;
66
+ const [showAnswerArrow, setShowAnswerArrow] = useState(false);
67
+ const [incorrectActive, setIncorrectActive] = useState(false);
68
+ const { revision, bumpRevision } = useBoardRevision();
69
+ const boardOrientationRef = useRef('white');
70
+ const boardFenRef = useRef(EMPTY_BOARD_FEN);
71
+ const notifyHost = () => {
72
+ incInteractionNum();
73
+ };
74
+ const expectedUci = (_a = position === null || position === void 0 ? void 0 : position.getExpectedMoveUci()) !== null && _a !== void 0 ? _a : null;
75
+ const positionFen = (_b = position === null || position === void 0 ? void 0 : position.fen()) !== null && _b !== void 0 ? _b : boardFenRef.current;
76
+ const useRefutation = showRefutationOnIncorrect && showAnswerArrowOnIncorrect;
77
+ /**
78
+ * Force a chessboard remount after a rejected drop so pieces snap back.
79
+ * Skip when refutation feedback drives `displayFen` — remounting blinks the
80
+ * whole board without helping snap-back.
81
+ */
82
+ const snapBoardBack = () => {
83
+ if (useRefutation) {
84
+ return;
85
+ }
86
+ bumpRevision();
87
+ incInteractionNum();
88
+ };
89
+ const missBoard = useMissBoard({
90
+ feedback: useRefutation && incorrectActive ? 'incorrect' : null,
91
+ expectedUci: expectedUci || null,
92
+ positionFen,
93
+ answerArrowColor,
94
+ autoShowWrongMoves,
95
+ engineOptions: refutationEngine,
96
+ });
97
+ const missPhase = (_c = missBoard.missSequence.sequence) === null || _c === void 0 ? void 0 : _c.phase;
98
+ const answerArrowVisible = useRefutation
99
+ ? incorrectActive && missPhase === 'answer'
100
+ : showAnswerArrow;
101
+ useEffect(() => {
102
+ setShowAnswerArrow(false);
103
+ setIncorrectActive(false);
104
+ onMissFeedbackChange === null || onMissFeedbackChange === void 0 ? void 0 : onMissFeedbackChange(null);
105
+ }, [onMissFeedbackChange, position]);
106
+ useEffect(() => {
107
+ var _a, _b;
108
+ if (!onMissFeedbackChange) {
109
+ return;
110
+ }
111
+ if (useRefutation && incorrectActive) {
112
+ onMissFeedbackChange({
113
+ refutationSan: missBoard.refutation.refutationSan,
114
+ phase: (_b = (_a = missBoard.missSequence.sequence) === null || _a === void 0 ? void 0 : _a.phase) !== null && _b !== void 0 ? _b : null,
115
+ answerArrowVisible,
116
+ });
117
+ return;
118
+ }
119
+ if (showAnswerArrow) {
120
+ onMissFeedbackChange({
121
+ refutationSan: null,
122
+ phase: null,
123
+ answerArrowVisible: true,
124
+ });
125
+ return;
126
+ }
127
+ onMissFeedbackChange(null);
128
+ }, [
129
+ answerArrowVisible,
130
+ incorrectActive,
131
+ (_d = missBoard.missSequence.sequence) === null || _d === void 0 ? void 0 : _d.phase,
132
+ missBoard.refutation.refutationSan,
133
+ onMissFeedbackChange,
134
+ showAnswerArrow,
135
+ useRefutation,
136
+ ]);
137
+ if (position) {
138
+ boardOrientationRef.current = position.getPlayerColor();
139
+ boardFenRef.current = position.fen();
140
+ }
141
+ const boardOrientation = boardOrientationRef.current;
142
+ const boardFen = boardFenRef.current;
143
+ const hasBoard = boardFen !== EMPTY_BOARD_FEN;
144
+ const simpleArrows = useMemo(() => {
145
+ if (!showAnswerArrow || !position || useRefutation) {
146
+ return [];
147
+ }
148
+ const moveUci = position.getExpectedMoveUci();
149
+ if (moveUci.length < 4) {
150
+ return [];
151
+ }
152
+ return [[moveUci.slice(0, 2), moveUci.slice(2, 4), answerArrowColor]];
153
+ }, [showAnswerArrow, position, answerArrowColor, useRefutation]);
154
+ const customArrows = useRefutation && incorrectActive
155
+ ? missBoard.customArrows
156
+ : simpleArrows;
157
+ const displayFen = useRefutation && incorrectActive ? missBoard.boardPosition : boardFen;
158
+ const missLocked = useRefutation &&
159
+ incorrectActive &&
160
+ (missBoard.boardAnimating ||
161
+ missPhase === 'wrong' ||
162
+ missPhase === 'refutation');
163
+ const arePiecesDraggable = position !== null && !positionLocked && !missLocked;
67
164
  const onPieceDrop = (sourceSquare, targetSquare, piece) => {
68
- if (!position || position.isSolutionRevealed()) {
165
+ if (!position || positionLocked || position.isSolutionRevealed()) {
166
+ return false;
167
+ }
168
+ if (position.hasResumeConfig() && !position.isQuizIndex()) {
69
169
  return false;
70
170
  }
71
171
  if (!position.isLegalMove(sourceSquare, targetSquare)) {
72
172
  return false;
73
173
  }
74
- const guess = position.tryGuess(sourceSquare, targetSquare, piece);
174
+ if (answerArrowVisible &&
175
+ !allowRetryOnIncorrect &&
176
+ !position.isExpectedGuess(sourceSquare, targetSquare)) {
177
+ position.resetInteractions();
178
+ snapBoardBack();
179
+ return false;
180
+ }
181
+ const guess = position.tryGuess(sourceSquare, targetSquare, piece, {
182
+ recordIfIncorrect: !(answerArrowVisible && !allowRetryOnIncorrect),
183
+ });
75
184
  if (!guess.accepted) {
76
185
  onFeedback({
77
186
  index: position.getIndex(),
78
187
  guess: { sourceSquare, targetSquare, piece },
79
188
  isCorrect: false,
80
189
  });
81
- incInteractionNum();
82
- setTimeout(() => {
83
- if (revealAnswerOnIncorrect) {
190
+ if (useRefutation) {
191
+ const setupFen = position.fen();
192
+ const attemptedUci = uciFromDrop(setupFen, sourceSquare, targetSquare, piece);
193
+ setIncorrectActive(true);
194
+ if (attemptedUci) {
195
+ missBoard.missSequence.startSequence(setupFen, attemptedUci);
196
+ }
197
+ position.resetInteractions();
198
+ snapBoardBack();
199
+ return false;
200
+ }
201
+ const revealIncorrectFeedback = () => {
202
+ if (showAnswerArrowOnIncorrect) {
203
+ position.resetInteractions();
204
+ setShowAnswerArrow(true);
205
+ }
206
+ else if (revealAnswerOnIncorrect) {
84
207
  position.resetInteractions();
85
208
  position.revealCorrectMove();
86
209
  }
87
210
  else {
88
211
  position.resetInteractions();
89
212
  }
90
- incInteractionNum();
91
- }, 500);
213
+ snapBoardBack();
214
+ };
215
+ if (showAnswerArrowOnIncorrect && !allowRetryOnIncorrect) {
216
+ revealIncorrectFeedback();
217
+ }
218
+ else {
219
+ setTimeout(revealIncorrectFeedback, 500);
220
+ }
92
221
  return false;
93
222
  }
223
+ setShowAnswerArrow(false);
224
+ setIncorrectActive(false);
225
+ missBoard.missSequence.clearSequence();
226
+ onMissFeedbackChange === null || onMissFeedbackChange === void 0 ? void 0 : onMissFeedbackChange(null);
94
227
  onFeedback({
95
228
  index: position.getIndex(),
96
229
  guess: { sourceSquare, targetSquare, piece },
97
230
  isCorrect: true,
98
231
  isFinished: guess.finished,
99
232
  });
100
- incInteractionNum();
233
+ notifyHost();
101
234
  setTimeout(() => {
102
235
  position.resetInteractions();
103
- incInteractionNum();
236
+ notifyHost();
104
237
  }, 500);
105
238
  if (position.isAlternativeCheckmate()) {
106
- incInteractionNum();
239
+ notifyHost();
107
240
  return true;
108
241
  }
109
242
  position.next();
110
- incInteractionNum();
243
+ notifyHost();
244
+ if (position.hasResumeConfig()) {
245
+ onResumeCorrect === null || onResumeCorrect === void 0 ? void 0 : onResumeCorrect(position);
246
+ return true;
247
+ }
111
248
  setTimeout(() => {
112
249
  if (!position.isFinished()) {
113
250
  position.next();
114
251
  }
115
- incInteractionNum();
252
+ notifyHost();
116
253
  }, 500);
117
254
  return true;
118
255
  };
119
- return (jsx(ChessboardDnDProvider, { children: jsx(HighlightChessboard, { boardWidth: boardWidth, checkSquare: (_a = position === null || position === void 0 ? void 0 : position.getCheckSquare()) !== null && _a !== void 0 ? _a : '', hintSquare: (_b = position === null || position === void 0 ? void 0 : position.getHintSquare()) !== null && _b !== void 0 ? _b : null, incorrectMoveSquare: (_c = position === null || position === void 0 ? void 0 : position.getIncorrectMoveSquare()) !== null && _c !== void 0 ? _c : null, onPieceDrop: onPieceDrop, position: (_d = position === null || position === void 0 ? void 0 : position.fen()) !== null && _d !== void 0 ? _d : EMPTY_BOARD_FEN, boardOrientation: (_e = position === null || position === void 0 ? void 0 : position.getPlayerColor()) !== null && _e !== void 0 ? _e : 'white', arePiecesDraggable: position !== null, promotionDialogVariant: "modal" }) }));
256
+ return (jsx(ChessboardDnDProvider, { children: hasBoard ? (jsx(HighlightChessboard, { boardWidth: boardWidth, checkSquare: (_e = position === null || position === void 0 ? void 0 : position.getCheckSquare()) !== null && _e !== void 0 ? _e : '', hintSquare: (_f = position === null || position === void 0 ? void 0 : position.getHintSquare()) !== null && _f !== void 0 ? _f : null, incorrectMoveSquare: showAnswerArrowOnIncorrect
257
+ ? null
258
+ : ((_g = position === null || position === void 0 ? void 0 : position.getIncorrectMoveSquare()) !== null && _g !== void 0 ? _g : null), customArrows: customArrows, onPieceDrop: onPieceDrop, position: displayFen, boardOrientation: boardOrientation, arePiecesDraggable: arePiecesDraggable, areArrowsAllowed: false, promotionDialogVariant: "modal", animationDuration: 0 }, revision)) : null }));
120
259
  };
121
260
 
122
- const PuzzleBoard = ({ position, onFeedback, incInteractionNum, boardWidth, revealAnswerOnIncorrect = false, }) => (jsx(PuzzlePlaySurface, { position: position, onFeedback: onFeedback, incInteractionNum: incInteractionNum, boardWidth: boardWidth, revealAnswerOnIncorrect: revealAnswerOnIncorrect }));
261
+ const PuzzleBoard = ({ position, onFeedback, incInteractionNum, boardWidth, revealAnswerOnIncorrect = false, showAnswerArrowOnIncorrect = false, allowRetryOnIncorrect = true, answerArrowColor, }) => (jsx(PuzzlePlaySurface, { position: position, onFeedback: onFeedback, incInteractionNum: incInteractionNum, boardWidth: boardWidth, revealAnswerOnIncorrect: revealAnswerOnIncorrect, showAnswerArrowOnIncorrect: showAnswerArrowOnIncorrect, allowRetryOnIncorrect: allowRetryOnIncorrect, answerArrowColor: answerArrowColor }));
123
262
 
124
263
  const isAttemptFinished = (resultStatus) => resultStatus === 'complete' || resultStatus === 'incorrect';
125
264
  /** Library default hint / next / analysis / result controls (unstyled buttons). */
@@ -127,12 +266,10 @@ const DefaultPuzzleControls = ({ showHint, showSolution, nextPuzzle, resultStatu
127
266
  const defaultRenderControls = (showHint, showSolution, nextPuzzle, resultStatus, analysis, controlState) => (jsx(DefaultPuzzleControls, { showHint: showHint, showSolution: showSolution, nextPuzzle: nextPuzzle, resultStatus: resultStatus, analysis: analysis, controlState: controlState }));
128
267
  const rowStyle$1 = {
129
268
  display: 'flex',
130
- flexWrap: 'wrap',
131
- alignItems: 'center',
269
+ flexDirection: 'column',
270
+ alignItems: 'stretch',
132
271
  gap: 8,
133
272
  width: '100%',
134
- minHeight: 96,
135
- alignContent: 'flex-start',
136
273
  };
137
274
  const buttonStyle = {
138
275
  cursor: 'pointer',
@@ -150,27 +287,92 @@ const statusStyle$1 = {
150
287
 
151
288
  /** Default pixel width for the live puzzle board (analysis uses {@link DEFAULT_ANALYSIS_LAYOUT}). */
152
289
  const DEFAULT_PUZZLE_BOARD_WIDTH = 480;
153
- /** Minimum height reserved below the board so controls do not shift when status changes. */
154
- const PUZZLE_CONTROLS_MIN_HEIGHT = 96;
155
- const puzzlePlayColumnStyle = (boardWidth) => ({
290
+ /** Width reserved for the vertical controls column beside the board. */
291
+ const PUZZLE_CONTROLS_COLUMN_WIDTH = 160;
292
+ /** Gap between the board column and controls column. */
293
+ const PUZZLE_BOARD_CONTROLS_GAP = 16;
294
+ /** Viewports at or below this width stack controls under the board (through tablet landscape). */
295
+ const PUZZLE_CONTROLS_STACK_BREAKPOINT_PX = 1365;
296
+ /** Horizontal space taken by the controls column when it sits beside the board. */
297
+ const PUZZLE_CONTROLS_BESIDE_RESERVE_PX = PUZZLE_CONTROLS_COLUMN_WIDTH + PUZZLE_BOARD_CONTROLS_GAP;
298
+ /** Minimum height reserved below the board so the caption slot does not shift between loads. */
299
+ const PUZZLE_BOARD_CAPTION_MIN_HEIGHT = 24;
300
+ const puzzlePlayRowStyle = (placement = 'beside') => ({
301
+ display: 'flex',
302
+ flexDirection: placement === 'below' ? 'column' : 'row',
303
+ alignItems: placement === 'below' ? 'stretch' : 'flex-start',
304
+ gap: PUZZLE_BOARD_CONTROLS_GAP,
305
+ width: placement === 'below' ? '100%' : 'fit-content',
306
+ maxWidth: '100%',
307
+ });
308
+ const puzzleBoardColumnStyle = (boardWidth, placement = 'beside') => ({
156
309
  display: 'flex',
157
310
  flexDirection: 'column',
158
311
  width: boardWidth,
159
312
  maxWidth: '100%',
313
+ flexShrink: 0,
314
+ alignSelf: placement === 'below' ? 'center' : undefined,
315
+ });
316
+ const puzzleBoardCaptionSlotStyle = () => ({
317
+ width: '100%',
318
+ minHeight: PUZZLE_BOARD_CAPTION_MIN_HEIGHT,
319
+ flexShrink: 0,
320
+ display: 'flex',
321
+ justifyContent: 'center',
322
+ alignItems: 'center',
323
+ marginTop: 4,
324
+ });
325
+ const puzzleBoardSlotWrapperStyle = () => ({
326
+ position: 'relative',
327
+ width: '100%',
328
+ flexShrink: 0,
160
329
  });
161
330
  const puzzleBoardSlotStyle = () => ({
162
331
  width: '100%',
163
332
  aspectRatio: '1 / 1',
164
333
  flexShrink: 0,
165
334
  });
166
- const puzzleControlsSlotStyle = () => ({
167
- minHeight: PUZZLE_CONTROLS_MIN_HEIGHT,
168
- flexShrink: 0,
335
+ const puzzleControlsFeedbackStyle = (placement = 'beside') => ({
336
+ display: 'flex',
337
+ flexDirection: 'column',
338
+ alignItems: placement === 'below' ? 'center' : 'flex-end',
339
+ gap: 4,
340
+ marginTop: placement === 'below' ? 0 : 'auto',
341
+ pointerEvents: 'none',
342
+ });
343
+ const puzzleControlsSlotStyle = (placement = 'beside') => ({
169
344
  display: 'flex',
170
- alignItems: 'flex-start',
171
- marginTop: 8,
345
+ flexDirection: 'column',
346
+ alignItems: 'stretch',
347
+ gap: 8,
348
+ flexShrink: 0,
349
+ minWidth: placement === 'below' ? undefined : PUZZLE_CONTROLS_COLUMN_WIDTH,
350
+ width: placement === 'below' ? '100%' : undefined,
351
+ alignSelf: 'stretch',
172
352
  });
173
353
 
354
+ const stackControlsQuery = `(max-width: ${PUZZLE_CONTROLS_STACK_BREAKPOINT_PX}px)`;
355
+ const readStackControlsBelow = () => {
356
+ if (typeof window === 'undefined') {
357
+ return false;
358
+ }
359
+ return window.matchMedia(stackControlsQuery).matches;
360
+ };
361
+ /** True when hint / next controls should sit below the board instead of beside it. */
362
+ const useStackPuzzleControlsBelow = () => {
363
+ const [stackBelow, setStackBelow] = useState(readStackControlsBelow);
364
+ useEffect(() => {
365
+ const mediaQueryList = window.matchMedia(stackControlsQuery);
366
+ const onChange = (event) => {
367
+ setStackBelow(event.matches);
368
+ };
369
+ mediaQueryList.addEventListener('change', onChange);
370
+ setStackBelow(mediaQueryList.matches);
371
+ return () => mediaQueryList.removeEventListener('change', onChange);
372
+ }, []);
373
+ return stackBelow;
374
+ };
375
+
174
376
  /** Apply a UCI move (e.g. `e7e8q`) without throwing. */
175
377
  function applyUciMove(chess, uci) {
176
378
  if (!uci || uci.length < 4) {
@@ -231,6 +433,9 @@ class Position {
231
433
  fen() {
232
434
  return this.chess.fen();
233
435
  }
436
+ getSideToMove() {
437
+ return this.chess.turn() === 'w' ? 'white' : 'black';
438
+ }
234
439
  getCheckSquare() {
235
440
  return getCheckSquareFromChess(this.chess);
236
441
  }
@@ -260,7 +465,7 @@ function playerColorForSolution(initialFen, moves) {
260
465
  return chess.turn() === 'w' ? 'white' : 'black';
261
466
  }
262
467
  class PuzzlePosition extends Position {
263
- constructor(initialFEN, moves) {
468
+ constructor(initialFEN, moves, resumeConfig) {
264
469
  super();
265
470
  this.isCorrect = false;
266
471
  this.guessedMove = '';
@@ -268,7 +473,7 @@ class PuzzlePosition extends Position {
268
473
  this.solutionRevealed = false;
269
474
  this.moveHistory = [];
270
475
  this.usedAlternativeCheckmate = false;
271
- this.tryGuess = (sourceSquare, targetSquare, piece) => {
476
+ this.tryGuess = (sourceSquare, targetSquare, piece, options) => {
272
477
  var _a;
273
478
  const candidates = this.buildCandidateUcis(sourceSquare, targetSquare);
274
479
  if (candidates.length === 0) {
@@ -294,12 +499,14 @@ class PuzzlePosition extends Position {
294
499
  }
295
500
  this.isCorrect = false;
296
501
  this.guessedMove = `${sourceSquare}${targetSquare}`;
297
- this.moveHistory.push({
298
- ply: this.moveHistory.length,
299
- uci: (_a = candidates[candidates.length - 1]) !== null && _a !== void 0 ? _a : this.guessedMove,
300
- actor: 'attempt',
301
- isCorrect: false,
302
- });
502
+ if ((options === null || options === void 0 ? void 0 : options.recordIfIncorrect) !== false) {
503
+ this.moveHistory.push({
504
+ ply: this.moveHistory.length,
505
+ uci: (_a = candidates[candidates.length - 1]) !== null && _a !== void 0 ? _a : this.guessedMove,
506
+ actor: 'attempt',
507
+ isCorrect: false,
508
+ });
509
+ }
303
510
  return { accepted: false, finished: false };
304
511
  };
305
512
  this.judgeGuess = (sourceSquare, targetSquare, piece) => this.tryGuess(sourceSquare, targetSquare, piece).accepted;
@@ -307,6 +514,12 @@ class PuzzlePosition extends Position {
307
514
  this.chess.load(initialFEN);
308
515
  this.moves = moves;
309
516
  this.playerColor = playerColorForSolution(initialFEN, moves);
517
+ this.resumeConfig = resumeConfig;
518
+ if (resumeConfig) {
519
+ for (let j = 0; j < resumeConfig.startIndex; j++) {
520
+ super.next();
521
+ }
522
+ }
310
523
  }
311
524
  next() {
312
525
  if (this.i >= this.moves.length) {
@@ -419,6 +632,12 @@ class PuzzlePosition extends Position {
419
632
  this.usedAlternativeCheckmate = true;
420
633
  return true;
421
634
  }
635
+ /** True when dragging from/to matches the expected move at the current index. */
636
+ isExpectedGuess(sourceSquare, targetSquare) {
637
+ const candidates = this.buildCandidateUcis(sourceSquare, targetSquare);
638
+ const expectedUci = this.moves[this.i];
639
+ return candidates.some((uci) => uci === expectedUci);
640
+ }
422
641
  resetInteractions() {
423
642
  this.guessedMove = '';
424
643
  this.isHintWanted = false;
@@ -456,9 +675,27 @@ class PuzzlePosition extends Position {
456
675
  }
457
676
  return this.guessedMove.slice(2, 4);
458
677
  }
678
+ /** UCI of the move the player should play at the current index. */
679
+ getExpectedMoveUci() {
680
+ var _a;
681
+ return (_a = this.moves[this.i]) !== null && _a !== void 0 ? _a : '';
682
+ }
459
683
  getPlayerColor() {
460
684
  return this.playerColor;
461
685
  }
686
+ hasResumeConfig() {
687
+ return this.resumeConfig !== undefined;
688
+ }
689
+ getResumeConfig() {
690
+ return this.resumeConfig;
691
+ }
692
+ /** True when the user must find the move at the current index. */
693
+ isQuizIndex() {
694
+ if (!this.resumeConfig) {
695
+ return !this.isFinished();
696
+ }
697
+ return this.resumeConfig.quizAtIndices.includes(this.i);
698
+ }
462
699
  }
463
700
  class GamePosition extends Position {
464
701
  constructor(PGN) {
@@ -471,19 +708,36 @@ class GamePosition extends Position {
471
708
  }
472
709
  }
473
710
 
474
- /** Delay before auto-playing the opponent's opening move (ms). */
475
- const OPPONENT_OPENING_MOVE_DELAY_MS = 500;
711
+ /** Apply the opponent setup ply immediately so the board does not flash on load. */
712
+ const puzzlePositionFromFetch = (fen, moves, resume) => {
713
+ const newPosition = new PuzzlePosition(fen, moves, resume);
714
+ if (!resume && moves.length > 1) {
715
+ newPosition.next();
716
+ }
717
+ return newPosition;
718
+ };
476
719
  /** Brief pause so the user sees a correct result before the next card loads. */
477
720
  const AUTO_ADVANCE_ON_COMPLETE_DELAY_MS = 700;
478
721
  const SOLUTION_STEP_MS = 500;
479
- const PuzzleBoardWithControls = ({ theme, apiProxy, renderControls = defaultRenderControls, renderAnalysisSidebar, renderAnalysisContainer, renderEngineEvaluation, puzzleBoardWidth = DEFAULT_PUZZLE_BOARD_WIDTH, analysisLayout = DEFAULT_ANALYSIS_LAYOUT, renderAnalysisMain, engine, autoAdvanceOnComplete = false, revealAnswerOnIncorrect = false, }) => {
722
+ const RESUME_AUTO_STEP_MS = 500;
723
+ const PuzzleBoardWithControls = ({ theme, boardTheme, apiProxy, renderControls = defaultRenderControls, renderAnalysisSidebar, renderAnalysisContainer, renderEngineEvaluation, renderBoardCaption, renderBoardFeedback, puzzleBoardWidth = DEFAULT_PUZZLE_BOARD_WIDTH, analysisLayout = DEFAULT_ANALYSIS_LAYOUT, renderAnalysisMain, engine, autoAdvanceOnComplete = false, autoAdvanceOnCompleteAfterIncorrect = false, revealAnswerOnIncorrect = false, showAnswerArrowOnIncorrect = false, allowRetryOnIncorrect = true, showRefutationOnIncorrect, autoShowWrongMoves = true, refutationEngine, answerArrowColor, }) => {
724
+ var _a, _b, _c, _d;
725
+ const refutationOnIncorrect = showRefutationOnIncorrect !== null && showRefutationOnIncorrect !== void 0 ? showRefutationOnIncorrect : showAnswerArrowOnIncorrect;
726
+ const stackControlsBelow = useStackPuzzleControlsBelow();
727
+ const controlsPlacement = stackControlsBelow
728
+ ? 'below'
729
+ : 'beside';
480
730
  const { onFetch, onFetchError, onFeedback } = apiProxy;
481
731
  const [position, setPosition] = useState(null);
732
+ const [loadingNextPuzzle, setLoadingNextPuzzle] = useState(true);
482
733
  const [puzzleNum, setPuzzleNum] = useState(0);
483
734
  const [hasIncorrectAttempt, setHasIncorrectAttempt] = useState(false);
484
735
  const [puzzleComplete, setPuzzleComplete] = useState(false);
736
+ const [completedAfterMiss, setCompletedAfterMiss] = useState(false);
737
+ const [missFeedback, setMissFeedback] = useState(null);
485
738
  const [, setInteractionNum] = useState(0);
486
739
  const solutionAnimationRef = useRef({ cancelled: false, timeoutIds: [] });
740
+ const resumeAnimationRef = useRef({ cancelled: false, timeoutIds: [] });
487
741
  const incInteractionNum = () => {
488
742
  setInteractionNum((prev) => prev + 1);
489
743
  };
@@ -493,57 +747,58 @@ const PuzzleBoardWithControls = ({ theme, apiProxy, renderControls = defaultRend
493
747
  anim.timeoutIds.forEach(clearTimeout);
494
748
  solutionAnimationRef.current = { cancelled: false, timeoutIds: [] };
495
749
  };
750
+ const clearResumeAnimation = () => {
751
+ const anim = resumeAnimationRef.current;
752
+ anim.cancelled = true;
753
+ anim.timeoutIds.forEach(clearTimeout);
754
+ resumeAnimationRef.current = { cancelled: false, timeoutIds: [] };
755
+ };
496
756
  useEffect(() => {
497
757
  let cancelled = false;
498
- let openingMoveTimeoutId;
499
- setPosition(null);
758
+ setLoadingNextPuzzle(true);
500
759
  setHasIncorrectAttempt(false);
501
760
  setPuzzleComplete(false);
761
+ setCompletedAfterMiss(false);
762
+ setMissFeedback(null);
502
763
  onFetch()
503
764
  .then((data) => {
504
765
  if (cancelled) {
505
766
  return;
506
767
  }
507
- if (!data || !data.fen || !data.moves) {
768
+ if (!(data === null || data === void 0 ? void 0 : data.fen) || !Array.isArray(data.moves) || data.moves.length === 0) {
508
769
  console.error('Invalid data fetched:', data);
770
+ setLoadingNextPuzzle(false);
509
771
  return;
510
772
  }
511
- const newPosition = new PuzzlePosition(data.fen, data.moves);
512
- setPosition(newPosition);
513
- // Multi-move puzzles lead with an opponent setup ply; single-move lines
514
- // (e.g. a first-ply opening trainer) are already on the player to move.
515
- if (data.moves.length > 1) {
516
- openingMoveTimeoutId = setTimeout(() => {
517
- if (cancelled) {
518
- return;
519
- }
520
- if (newPosition.next()) {
521
- incInteractionNum();
522
- }
523
- }, OPPONENT_OPENING_MOVE_DELAY_MS);
524
- }
773
+ setPosition(puzzlePositionFromFetch(data.fen, data.moves, data.resume));
774
+ requestAnimationFrame(() => {
775
+ if (!cancelled) {
776
+ setLoadingNextPuzzle(false);
777
+ }
778
+ });
525
779
  })
526
780
  .catch((error) => {
527
781
  if (!cancelled) {
782
+ setLoadingNextPuzzle(false);
528
783
  onFetchError === null || onFetchError === void 0 ? void 0 : onFetchError(error);
529
784
  }
530
785
  });
531
786
  return () => {
532
787
  cancelled = true;
533
788
  clearSolutionAnimation();
534
- if (openingMoveTimeoutId !== undefined) {
535
- clearTimeout(openingMoveTimeoutId);
536
- }
789
+ clearResumeAnimation();
537
790
  };
538
791
  }, [puzzleNum]);
539
792
  const handleFeedback = (feedbackData) => {
540
- if (feedbackData.hintRequested ||
793
+ const incorrectThisFeedback = feedbackData.hintRequested ||
541
794
  feedbackData.solutionShown ||
542
- feedbackData.isCorrect === false) {
795
+ feedbackData.isCorrect === false;
796
+ if (incorrectThisFeedback) {
543
797
  setHasIncorrectAttempt(true);
544
798
  }
545
799
  if (feedbackData.isFinished) {
546
800
  setPuzzleComplete(true);
801
+ setCompletedAfterMiss(hasIncorrectAttempt || incorrectThisFeedback);
547
802
  }
548
803
  onFeedback(feedbackData);
549
804
  };
@@ -637,6 +892,53 @@ const PuzzleBoardWithControls = ({ theme, apiProxy, renderControls = defaultRend
637
892
  };
638
893
  advance();
639
894
  };
895
+ const runResumeAutoAdvance = (pos) => {
896
+ clearResumeAnimation();
897
+ const anim = {
898
+ cancelled: false,
899
+ timeoutIds: [],
900
+ };
901
+ resumeAnimationRef.current = anim;
902
+ const schedule = (fn, ms) => {
903
+ const id = setTimeout(() => {
904
+ if (anim.cancelled) {
905
+ return;
906
+ }
907
+ fn();
908
+ }, ms);
909
+ anim.timeoutIds.push(id);
910
+ };
911
+ const finish = () => {
912
+ setPuzzleComplete(true);
913
+ handleFeedback({
914
+ index: pos.getIndex(),
915
+ isCorrect: true,
916
+ isFinished: true,
917
+ });
918
+ incInteractionNum();
919
+ };
920
+ const step = () => {
921
+ if (anim.cancelled) {
922
+ return;
923
+ }
924
+ if (pos.isFinished()) {
925
+ finish();
926
+ return;
927
+ }
928
+ if (pos.isQuizIndex()) {
929
+ return;
930
+ }
931
+ if (!pos.next()) {
932
+ if (pos.isFinished()) {
933
+ finish();
934
+ }
935
+ return;
936
+ }
937
+ incInteractionNum();
938
+ schedule(step, RESUME_AUTO_STEP_MS);
939
+ };
940
+ schedule(step, RESUME_AUTO_STEP_MS);
941
+ };
640
942
  const handleShowSolution = () => {
641
943
  if (!position) {
642
944
  return;
@@ -670,7 +972,10 @@ const PuzzleBoardWithControls = ({ theme, apiProxy, renderControls = defaultRend
670
972
  if (!autoAdvanceOnComplete) {
671
973
  return;
672
974
  }
673
- if (resultStatus !== 'complete' || hasIncorrectAttempt) {
975
+ if (resultStatus !== 'complete') {
976
+ return;
977
+ }
978
+ if (hasIncorrectAttempt && !autoAdvanceOnCompleteAfterIncorrect) {
674
979
  return;
675
980
  }
676
981
  const timer = setTimeout(() => {
@@ -681,6 +986,7 @@ const PuzzleBoardWithControls = ({ theme, apiProxy, renderControls = defaultRend
681
986
  };
682
987
  }, [
683
988
  autoAdvanceOnComplete,
989
+ autoAdvanceOnCompleteAfterIncorrect,
684
990
  resultStatus,
685
991
  hasIncorrectAttempt,
686
992
  handleNextPuzzle,
@@ -688,8 +994,9 @@ const PuzzleBoardWithControls = ({ theme, apiProxy, renderControls = defaultRend
688
994
  ]);
689
995
  const controlState = {
690
996
  canShowHint: position !== null &&
691
- resultStatus === 'none' &&
692
- !position.isSolutionRevealed(),
997
+ !position.isFinished() &&
998
+ !position.isSolutionRevealed() &&
999
+ !(hasIncorrectAttempt && showAnswerArrowOnIncorrect && !allowRetryOnIncorrect),
693
1000
  canShowSolution: position !== null &&
694
1001
  (position.isSolutionRevealed() || !position.isFinished()),
695
1002
  };
@@ -698,10 +1005,27 @@ const PuzzleBoardWithControls = ({ theme, apiProxy, renderControls = defaultRend
698
1005
  const useHostAnalysisUi = Boolean(renderAnalysisSidebar &&
699
1006
  renderAnalysisContainer &&
700
1007
  (renderEngineEvaluation || (engine === null || engine === void 0 ? void 0 : engine.enabled) === false));
701
- return (jsx(ThemeProvider, { theme: theme, children: analysisSnapshot ? (jsx(AnalysisErrorBoundary, { onClose: analysis.closeAnalysis, children: useHostAnalysisUi ? (jsx(AnalysisBoardCore, { analysisContext: analysisSnapshot, onClose: analysis.closeAnalysis, theme: theme, boardWidth: analysisLayout.boardWidth, engine: engine, renderMain: renderAnalysisMain !== null && renderAnalysisMain !== void 0 ? renderAnalysisMain : (({ board, sidebar, model }) => (jsx(AnalysisBoardLayout, { layout: analysisLayout, model: model, board: board, sidebar: sidebar }))), renderSidebar: renderAnalysisSidebar, renderContainer: renderAnalysisContainer, renderEngineEvaluation: renderEngineEvaluation !== null && renderEngineEvaluation !== void 0 ? renderEngineEvaluation : (() => null) })) : (jsx(AnalysisBoard, { analysisContext: analysisSnapshot, onClose: analysis.closeAnalysis, theme: theme, layout: analysisLayout, engine: engine, renderMain: renderAnalysisMain, renderSidebar: renderAnalysisSidebar, renderContainer: renderAnalysisContainer, renderEngineEvaluation: renderEngineEvaluation })) })) : (jsxs("div", { style: puzzlePlayColumnStyle(puzzleBoardWidth), children: [jsx("div", { style: puzzleBoardSlotStyle(), children: jsx(PuzzlePlaySurface, { position: position, boardWidth: puzzleBoardWidth, onFeedback: handleFeedback, incInteractionNum: incInteractionNum, revealAnswerOnIncorrect: revealAnswerOnIncorrect }) }), jsx("div", { style: puzzleControlsSlotStyle(), children: renderControls(handleHintRequest, handleShowSolution, handleNextPuzzle, resultStatus, {
702
- visible: analysis.canOpen,
703
- openAnalysis: analysis.openAnalysis,
704
- }, controlState) })] })) }));
1008
+ return (jsx(ThemeProvider, { theme: theme, boardTheme: boardTheme, children: analysisSnapshot ? (jsx(AnalysisErrorBoundary, { onClose: analysis.closeAnalysis, children: useHostAnalysisUi ? (jsx(AnalysisBoardCore, { analysisContext: analysisSnapshot, onClose: analysis.closeAnalysis, theme: theme, boardWidth: analysisLayout.boardWidth, engine: engine, renderMain: renderAnalysisMain !== null && renderAnalysisMain !== void 0 ? renderAnalysisMain : (({ board, sidebar, model }) => (jsx(AnalysisBoardLayout, { layout: analysisLayout, model: model, board: board, sidebar: sidebar }))), renderSidebar: renderAnalysisSidebar, renderContainer: renderAnalysisContainer, renderEngineEvaluation: renderEngineEvaluation !== null && renderEngineEvaluation !== void 0 ? renderEngineEvaluation : (() => null) })) : (jsx(AnalysisBoard, { analysisContext: analysisSnapshot, onClose: analysis.closeAnalysis, theme: theme, layout: analysisLayout, engine: engine, renderMain: renderAnalysisMain, renderSidebar: renderAnalysisSidebar, renderContainer: renderAnalysisContainer, renderEngineEvaluation: renderEngineEvaluation })) })) : (jsxs("div", { style: puzzlePlayRowStyle(controlsPlacement), children: [jsxs("div", { style: puzzleBoardColumnStyle(puzzleBoardWidth, controlsPlacement), children: [jsx("div", { style: puzzleBoardSlotWrapperStyle(), children: jsx("div", { style: puzzleBoardSlotStyle(), children: jsx(PuzzlePlaySurface, { position: position, boardWidth: puzzleBoardWidth, onFeedback: handleFeedback, incInteractionNum: incInteractionNum, onResumeCorrect: runResumeAutoAdvance, revealAnswerOnIncorrect: revealAnswerOnIncorrect, showAnswerArrowOnIncorrect: showAnswerArrowOnIncorrect, allowRetryOnIncorrect: allowRetryOnIncorrect, showRefutationOnIncorrect: refutationOnIncorrect, autoShowWrongMoves: autoShowWrongMoves, refutationEngine: refutationEngine !== null && refutationEngine !== void 0 ? refutationEngine : engine, answerArrowColor: answerArrowColor, positionLocked: loadingNextPuzzle, onMissFeedbackChange: setMissFeedback }) }) }), renderBoardCaption && (jsx("div", { style: puzzleBoardCaptionSlotStyle(), children: renderBoardCaption({
1009
+ sideToMove: (_a = position === null || position === void 0 ? void 0 : position.getSideToMove()) !== null && _a !== void 0 ? _a : null,
1010
+ playerColor: position
1011
+ ? position.getPlayerColor()
1012
+ : null,
1013
+ incorrectAttempt: resultStatus === 'incorrect',
1014
+ complete: resultStatus === 'complete',
1015
+ cleanSolve: !hasIncorrectAttempt,
1016
+ refutationSan: (_b = missFeedback === null || missFeedback === void 0 ? void 0 : missFeedback.refutationSan) !== null && _b !== void 0 ? _b : null,
1017
+ missPhase: (_c = missFeedback === null || missFeedback === void 0 ? void 0 : missFeedback.phase) !== null && _c !== void 0 ? _c : null,
1018
+ answerArrowVisible: (_d = missFeedback === null || missFeedback === void 0 ? void 0 : missFeedback.answerArrowVisible) !== null && _d !== void 0 ? _d : false,
1019
+ completedAfterMiss,
1020
+ }) }))] }), jsxs("div", { style: puzzleControlsSlotStyle(controlsPlacement), children: [renderControls(handleHintRequest, handleShowSolution, handleNextPuzzle, resultStatus, {
1021
+ visible: analysis.canOpen,
1022
+ openAnalysis: analysis.openAnalysis,
1023
+ }, controlState), renderBoardFeedback && resultStatus === 'complete' && (jsx("div", { style: puzzleControlsFeedbackStyle(controlsPlacement), children: renderBoardFeedback({
1024
+ resultStatus,
1025
+ cleanSolve: !hasIncorrectAttempt,
1026
+ refutationSan: missFeedback === null || missFeedback === void 0 ? void 0 : missFeedback.refutationSan,
1027
+ missPhase: missFeedback === null || missFeedback === void 0 ? void 0 : missFeedback.phase,
1028
+ }) }))] })] })) }));
705
1029
  };
706
1030
 
707
1031
  /**
@@ -744,7 +1068,7 @@ const boardOrientationForLine = (side) => side === 'b' ? 'black' : 'white';
744
1068
  * tactical puzzles. Mount one instance per drill (key it by line) so its
745
1069
  * internal state resets between lines.
746
1070
  */
747
- const LineBoardWithControls = ({ theme, line, onComplete, onMove, renderControls = defaultRenderLineControls, boardWidth = DEFAULT_PUZZLE_BOARD_WIDTH, opponentMoveDelayMs = DEFAULT_OPPONENT_MOVE_DELAY_MS, }) => {
1071
+ const LineBoardWithControls = ({ theme, boardTheme, line, onComplete, onMove, renderControls = defaultRenderLineControls, boardWidth = DEFAULT_PUZZLE_BOARD_WIDTH, opponentMoveDelayMs = DEFAULT_OPPONENT_MOVE_DELAY_MS, }) => {
748
1072
  const chessRef = useRef(new Chess(line.startFen));
749
1073
  const perMoveRef = useRef([]);
750
1074
  const completedRef = useRef(false);
@@ -808,8 +1132,8 @@ const LineBoardWithControls = ({ theme, line, onComplete, onMove, renderControls
808
1132
  completedRef.current = true;
809
1133
  onCompleteRef.current(perMoveRef.current);
810
1134
  }, [finished]);
811
- const handleDrop = (source, target) => {
812
- var _a, _b, _c, _d;
1135
+ const handleDrop = (source, target, piece) => {
1136
+ var _a, _b, _c;
813
1137
  if (finished) {
814
1138
  return false;
815
1139
  }
@@ -817,31 +1141,27 @@ const LineBoardWithControls = ({ theme, line, onComplete, onMove, renderControls
817
1141
  return false;
818
1142
  }
819
1143
  const index = currentIndex;
820
- let userUci;
821
- try {
822
- const probe = new Chess(chessRef.current.fen());
823
- const move = probe.move({ from: source, to: target, promotion: 'q' });
824
- if (!move) {
825
- return false;
826
- }
827
- userUci = `${move.from}${move.to}${(_a = move.promotion) !== null && _a !== void 0 ? _a : ''}`;
828
- }
829
- catch (_e) {
1144
+ const expected = line.movesUci[index];
1145
+ const dropResult = evaluateExpectedMoveDrop(chessRef.current.fen(), source, target, piece, expected, true);
1146
+ if (dropResult.kind === 'illegal' || dropResult.kind === 'ignored') {
830
1147
  return false;
831
1148
  }
832
- const expected = line.movesUci[index];
833
- const isCorrect = userUci.toLowerCase() === expected.toLowerCase();
834
- const expectedSan = (_c = (_b = line.movesSan) === null || _b === void 0 ? void 0 : _b[index]) !== null && _c !== void 0 ? _c : expected;
1149
+ const isCorrect = dropResult.kind === 'correct';
1150
+ const expectedSan = (_b = (_a = line.movesSan) === null || _a === void 0 ? void 0 : _a[index]) !== null && _b !== void 0 ? _b : expected;
835
1151
  perMoveRef.current.push({ index, isCorrect });
836
1152
  const moveFeedback = { index, isCorrect, expectedSan };
837
1153
  setFeedback(moveFeedback);
838
- (_d = onMoveRef.current) === null || _d === void 0 ? void 0 : _d.call(onMoveRef, moveFeedback);
1154
+ (_c = onMoveRef.current) === null || _c === void 0 ? void 0 : _c.call(onMoveRef, moveFeedback);
839
1155
  applyMove(index);
840
1156
  return isCorrect;
841
1157
  };
842
1158
  const moveNumber = Math.min(currentIndex + 1, total);
843
1159
  const isUserTurn = !finished && turnFromFen(fen) === line.trainSide && currentIndex < total;
844
- return (jsx(ThemeProvider, { theme: theme, children: jsxs("div", { style: puzzlePlayColumnStyle(boardWidth), children: [jsx("div", { style: puzzleBoardSlotStyle(), children: jsx(LineBoard, { fen: fen, orientation: orientation, trainSide: line.trainSide, draggable: isUserTurn, onPieceDrop: handleDrop, boardWidth: boardWidth }) }), jsx("div", { style: puzzleControlsSlotStyle(), children: renderControls({
1160
+ const stackControlsBelow = useStackPuzzleControlsBelow();
1161
+ const controlsPlacement = stackControlsBelow
1162
+ ? 'below'
1163
+ : 'beside';
1164
+ return (jsx(ThemeProvider, { theme: theme, boardTheme: boardTheme, children: jsxs("div", { style: puzzlePlayRowStyle(controlsPlacement), children: [jsx("div", { style: puzzleBoardColumnStyle(boardWidth, controlsPlacement), children: jsx("div", { style: puzzleBoardSlotStyle(), children: jsx(LineBoard, { fen: fen, orientation: orientation, trainSide: line.trainSide, draggable: isUserTurn, onPieceDrop: handleDrop, boardWidth: boardWidth }) }) }), jsx("div", { style: puzzleControlsSlotStyle(controlsPlacement), children: renderControls({
845
1165
  trainSide: line.trainSide,
846
1166
  moveNumber,
847
1167
  total,
@@ -858,4 +1178,4 @@ const LineBoardWithControls = ({ theme, line, onComplete, onMove, renderControls
858
1178
  /** @deprecated Import {@link boardSquareHighlightColors} and {@link analysisBoardHighlightColors} from `react-chess-core`. */
859
1179
  const squareHighlightColors = Object.assign(Object.assign({}, boardSquareHighlightColors), analysisBoardHighlightColors);
860
1180
 
861
- export { DEFAULT_PUZZLE_BOARD_WIDTH, DefaultLineControls, DefaultPuzzleControls, GamePosition, LineBoard, LineBoardWithControls, Position, PuzzleBoard, PuzzleBoardWithControls, PuzzlePosition, applyUciMove, buildAnalysisContext, defaultRenderControls, defaultRenderLineControls, emptyAnalysisContext, getCheckSquareFromChess, isAnalysisAvailable, playerColorForSolution, squareHighlightColors, usePuzzleAnalysis };
1181
+ export { DEFAULT_PUZZLE_BOARD_WIDTH, DefaultLineControls, DefaultPuzzleControls, GamePosition, LineBoard, LineBoardWithControls, PUZZLE_CONTROLS_BESIDE_RESERVE_PX, PUZZLE_CONTROLS_STACK_BREAKPOINT_PX, Position, PuzzleBoard, PuzzleBoardWithControls, PuzzlePosition, applyUciMove, buildAnalysisContext, defaultRenderControls, defaultRenderLineControls, emptyAnalysisContext, getCheckSquareFromChess, isAnalysisAvailable, playerColorForSolution, squareHighlightColors, usePuzzleAnalysis };