@react-chess-tools/react-chess-puzzle 0.6.2 → 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.
package/package.json CHANGED
@@ -1,10 +1,19 @@
1
1
  {
2
2
  "name": "@react-chess-tools/react-chess-puzzle",
3
- "version": "0.6.2",
3
+ "version": "1.0.1",
4
4
  "description": "A lightweight, customizable React component library for rendering and interacting with chess puzzles.",
5
- "main": "dist/index.mjs",
6
- "module": "dist/index.mjs",
7
- "types": "dist/index.d.mts",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js",
10
+ "require": "./dist/index.cjs",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "main": "./dist/index.cjs",
15
+ "module": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
8
17
  "scripts": {
9
18
  "build": "tsup src/index.ts",
10
19
  "test": "echo \"Error: no test specified\" && exit 1"
@@ -32,14 +41,15 @@
32
41
  "author": "Daniele Cammareri <daniele.cammareri@gmail.com>",
33
42
  "license": "MIT",
34
43
  "dependencies": {
35
- "@react-chess-tools/react-chess-game": "0.5.2",
36
- "chess.js": "^1.0.0-beta.8",
44
+ "@radix-ui/react-slot": "^1.2.4",
45
+ "@react-chess-tools/react-chess-game": "1.0.1",
46
+ "chess.js": "^1.4.0",
37
47
  "lodash": "^4.17.21"
38
48
  },
39
49
  "devDependencies": {
40
- "@types/lodash": "^4.17.15",
41
- "react": "^19.0.0",
42
- "react-dom": "^19.0.0"
50
+ "@types/lodash": "^4.17.21",
51
+ "react": "^19.2.3",
52
+ "react-dom": "^19.2.3"
43
53
  },
44
54
  "peerDependencies": {
45
55
  "react": ">=16.14.0",
@@ -0,0 +1,300 @@
1
+ import type { Meta } from "@storybook/react";
2
+ import React, { useState } from "react";
3
+ import { ChessPuzzle } from "./index";
4
+ import { defaultPuzzleTheme } from "../../theme/defaults";
5
+ import type { ChessPuzzleTheme } from "../../theme/types";
6
+
7
+ const meta = {
8
+ title: "react-chess-puzzle/Theme/Playground",
9
+ component: ChessPuzzle.Root,
10
+ tags: ["theme", "puzzle"],
11
+ decorators: [
12
+ (Story) => (
13
+ <div style={{ maxWidth: "900px" }}>
14
+ <Story />
15
+ </div>
16
+ ),
17
+ ],
18
+ } satisfies Meta<typeof ChessPuzzle.Root>;
19
+
20
+ export default meta;
21
+
22
+ const samplePuzzle = {
23
+ fen: "4kb1r/p2r1ppp/4qn2/1B2p1B1/4P3/1Q6/PPP2PPP/2KR4 w k - 0 1",
24
+ moves: ["Bxd7+", "Nxd7", "Qb8+", "Nxb8", "Rd8#"],
25
+ makeFirstMove: false,
26
+ };
27
+
28
+ // Color picker component
29
+ const ColorInput: React.FC<{
30
+ label: string;
31
+ value: string;
32
+ onChange: (value: string) => void;
33
+ }> = ({ label, value, onChange }) => {
34
+ const rgbaToHex = (rgba: string): string => {
35
+ const match = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
36
+ if (match) {
37
+ const r = parseInt(match[1]).toString(16).padStart(2, "0");
38
+ const g = parseInt(match[2]).toString(16).padStart(2, "0");
39
+ const b = parseInt(match[3]).toString(16).padStart(2, "0");
40
+ return `#${r}${g}${b}`;
41
+ }
42
+ return value.startsWith("#") ? value : "#000000";
43
+ };
44
+
45
+ const hexToRgba = (hex: string, alpha: number = 0.5): string => {
46
+ const r = parseInt(hex.slice(1, 3), 16);
47
+ const g = parseInt(hex.slice(3, 5), 16);
48
+ const b = parseInt(hex.slice(5, 7), 16);
49
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
50
+ };
51
+
52
+ return (
53
+ <div
54
+ style={{
55
+ display: "flex",
56
+ alignItems: "center",
57
+ gap: "8px",
58
+ marginBottom: "8px",
59
+ }}
60
+ >
61
+ <input
62
+ type="color"
63
+ value={rgbaToHex(value)}
64
+ onChange={(e) => onChange(hexToRgba(e.target.value))}
65
+ style={{ width: "40px", height: "30px", cursor: "pointer" }}
66
+ />
67
+ <span style={{ fontSize: "12px", minWidth: "100px" }}>{label}</span>
68
+ <input
69
+ type="text"
70
+ value={value}
71
+ onChange={(e) => onChange(e.target.value)}
72
+ style={{ fontSize: "11px", width: "180px", padding: "4px" }}
73
+ />
74
+ </div>
75
+ );
76
+ };
77
+
78
+ export const PuzzlePlayground = () => {
79
+ const [theme, setTheme] = useState<ChessPuzzleTheme>(defaultPuzzleTheme);
80
+ const [copied, setCopied] = useState(false);
81
+ const [puzzleKey, setPuzzleKey] = useState(0);
82
+
83
+ const updatePuzzleColor = (
84
+ key: keyof ChessPuzzleTheme["puzzle"],
85
+ value: string,
86
+ ) => {
87
+ setTheme((prev) => ({
88
+ ...prev,
89
+ puzzle: {
90
+ ...prev.puzzle,
91
+ [key]: value,
92
+ },
93
+ }));
94
+ };
95
+
96
+ const copyTheme = () => {
97
+ const themeCode = `const myPuzzleTheme: PartialChessPuzzleTheme = {
98
+ puzzle: ${JSON.stringify(theme.puzzle, null, 4)}
99
+ };`;
100
+ navigator.clipboard.writeText(themeCode);
101
+ setCopied(true);
102
+ setTimeout(() => setCopied(false), 2000);
103
+ };
104
+
105
+ const resetPuzzle = () => {
106
+ setPuzzleKey((k) => k + 1);
107
+ };
108
+
109
+ return (
110
+ <div style={{ display: "flex", gap: "24px", flexWrap: "wrap" }}>
111
+ <div style={{ flex: "1", minWidth: "300px" }}>
112
+ <h3 style={{ marginBottom: "16px" }}>Puzzle Theme Editor</h3>
113
+
114
+ <div style={{ marginBottom: "16px" }}>
115
+ <strong>Puzzle Colors</strong>
116
+ <div style={{ marginTop: "8px" }}>
117
+ <ColorInput
118
+ label="Success"
119
+ value={theme.puzzle.success}
120
+ onChange={(v) => updatePuzzleColor("success", v)}
121
+ />
122
+ <ColorInput
123
+ label="Failure"
124
+ value={theme.puzzle.failure}
125
+ onChange={(v) => updatePuzzleColor("failure", v)}
126
+ />
127
+ <ColorInput
128
+ label="Hint"
129
+ value={theme.puzzle.hint}
130
+ onChange={(v) => updatePuzzleColor("hint", v)}
131
+ />
132
+ </div>
133
+ </div>
134
+
135
+ <div style={{ display: "flex", gap: "8px", marginBottom: "16px" }}>
136
+ <button
137
+ onClick={copyTheme}
138
+ style={{
139
+ padding: "8px 16px",
140
+ backgroundColor: copied ? "#4caf50" : "#2196f3",
141
+ color: "white",
142
+ border: "none",
143
+ borderRadius: "4px",
144
+ cursor: "pointer",
145
+ }}
146
+ >
147
+ {copied ? "Copied!" : "Copy Theme Code"}
148
+ </button>
149
+ <button
150
+ onClick={resetPuzzle}
151
+ style={{
152
+ padding: "8px 16px",
153
+ backgroundColor: "#666",
154
+ color: "white",
155
+ border: "none",
156
+ borderRadius: "4px",
157
+ cursor: "pointer",
158
+ }}
159
+ >
160
+ Reset Puzzle
161
+ </button>
162
+ </div>
163
+
164
+ <div style={{ fontSize: "12px", color: "#666" }}>
165
+ <p>
166
+ <strong>How to test colors:</strong>
167
+ </p>
168
+ <ul style={{ paddingLeft: "16px" }}>
169
+ <li>Click "Hint" to see the hint color</li>
170
+ <li>Make a correct move to see success color</li>
171
+ <li>Make a wrong move to see failure color</li>
172
+ <li>Click "Reset" to try again</li>
173
+ </ul>
174
+ </div>
175
+ </div>
176
+
177
+ <div style={{ flex: "1", minWidth: "350px" }}>
178
+ <h3 style={{ marginBottom: "16px" }}>Preview</h3>
179
+ <div style={{ maxWidth: "400px" }}>
180
+ <ChessPuzzle.Root key={puzzleKey} puzzle={samplePuzzle} theme={theme}>
181
+ <ChessPuzzle.Board />
182
+ <div style={{ marginTop: "8px", display: "flex", gap: "8px" }}>
183
+ <ChessPuzzle.Hint asChild>
184
+ <button style={{ padding: "6px 12px" }}>Hint</button>
185
+ </ChessPuzzle.Hint>
186
+ <ChessPuzzle.Reset asChild>
187
+ <button style={{ padding: "6px 12px" }}>Reset</button>
188
+ </ChessPuzzle.Reset>
189
+ </div>
190
+ </ChessPuzzle.Root>
191
+ </div>
192
+ <p style={{ fontSize: "12px", color: "#666", marginTop: "8px" }}>
193
+ Solution: Bxd7+, Nxd7, Qb8+, Nxb8, Rd8#
194
+ </p>
195
+ </div>
196
+ </div>
197
+ );
198
+ };
199
+
200
+ export const PuzzleThemeExamples = () => {
201
+ const customThemes = {
202
+ default: defaultPuzzleTheme,
203
+ neon: {
204
+ ...defaultPuzzleTheme,
205
+ puzzle: {
206
+ success: "rgba(0, 255, 127, 0.6)",
207
+ failure: "rgba(255, 0, 127, 0.6)",
208
+ hint: "rgba(0, 191, 255, 0.6)",
209
+ },
210
+ },
211
+ pastel: {
212
+ ...defaultPuzzleTheme,
213
+ puzzle: {
214
+ success: "rgba(152, 251, 152, 0.6)",
215
+ failure: "rgba(255, 182, 193, 0.6)",
216
+ hint: "rgba(173, 216, 230, 0.6)",
217
+ },
218
+ },
219
+ };
220
+
221
+ return (
222
+ <div>
223
+ <h2 style={{ marginBottom: "24px" }}>Puzzle Theme Examples</h2>
224
+ <div
225
+ style={{
226
+ display: "grid",
227
+ gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
228
+ gap: "24px",
229
+ }}
230
+ >
231
+ {Object.entries(customThemes).map(([name, theme]) => (
232
+ <div key={name}>
233
+ <h4 style={{ marginBottom: "8px", textTransform: "capitalize" }}>
234
+ {name}
235
+ </h4>
236
+ <ChessPuzzle.Root puzzle={samplePuzzle} theme={theme}>
237
+ <ChessPuzzle.Board />
238
+ <div style={{ marginTop: "8px", display: "flex", gap: "8px" }}>
239
+ <ChessPuzzle.Hint asChild>
240
+ <button style={{ padding: "4px 8px", fontSize: "12px" }}>
241
+ Hint
242
+ </button>
243
+ </ChessPuzzle.Hint>
244
+ <ChessPuzzle.Reset asChild>
245
+ <button style={{ padding: "4px 8px", fontSize: "12px" }}>
246
+ Reset
247
+ </button>
248
+ </ChessPuzzle.Reset>
249
+ </div>
250
+ </ChessPuzzle.Root>
251
+ </div>
252
+ ))}
253
+ </div>
254
+ </div>
255
+ );
256
+ };
257
+
258
+ export const PartialPuzzleTheme = () => {
259
+ // Only override puzzle colors, inherit game colors from default
260
+ const partialTheme = {
261
+ puzzle: {
262
+ hint: "rgba(255, 215, 0, 0.6)", // Gold
263
+ },
264
+ };
265
+
266
+ return (
267
+ <div style={{ maxWidth: "500px" }}>
268
+ <h3>Partial Puzzle Theme</h3>
269
+ <p style={{ fontSize: "14px", color: "#666", marginBottom: "16px" }}>
270
+ Only override the hint color to gold. Success and failure use defaults.
271
+ </p>
272
+ <ChessPuzzle.Root puzzle={samplePuzzle} theme={partialTheme}>
273
+ <ChessPuzzle.Board />
274
+ <div style={{ marginTop: "8px", display: "flex", gap: "8px" }}>
275
+ <ChessPuzzle.Hint asChild>
276
+ <button style={{ padding: "6px 12px" }}>Show Gold Hint</button>
277
+ </ChessPuzzle.Hint>
278
+ <ChessPuzzle.Reset asChild>
279
+ <button style={{ padding: "6px 12px" }}>Reset</button>
280
+ </ChessPuzzle.Reset>
281
+ </div>
282
+ </ChessPuzzle.Root>
283
+ <details style={{ marginTop: "16px" }}>
284
+ <summary style={{ cursor: "pointer", fontSize: "14px" }}>
285
+ View theme code
286
+ </summary>
287
+ <pre
288
+ style={{
289
+ fontSize: "11px",
290
+ background: "#f5f5f5",
291
+ padding: "12px",
292
+ overflow: "auto",
293
+ }}
294
+ >
295
+ {JSON.stringify(partialTheme, null, 2)}
296
+ </pre>
297
+ </details>
298
+ </div>
299
+ );
300
+ };
@@ -1,46 +1,55 @@
1
1
  import React from "react";
2
- import { Status, isClickableElement } from "../../../utils";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { Status } from "../../../utils";
3
4
  import { useChessPuzzleContext } from "../../..";
4
5
 
5
- export interface HintProps {
6
+ export interface HintProps extends Omit<
7
+ React.ButtonHTMLAttributes<HTMLButtonElement>,
8
+ "onClick"
9
+ > {
6
10
  asChild?: boolean;
11
+ /**
12
+ * The puzzle statuses in which the hint button should be visible.
13
+ * @default ["not-started", "in-progress"]
14
+ */
7
15
  showOn?: Status[];
8
16
  }
9
17
 
10
18
  const defaultShowOn: Status[] = ["not-started", "in-progress"];
11
19
 
12
- export const Hint: React.FC<React.PropsWithChildren<HintProps>> = ({
13
- children,
14
- asChild,
15
- showOn = defaultShowOn,
16
- }) => {
20
+ export const Hint = React.forwardRef<
21
+ HTMLElement,
22
+ React.PropsWithChildren<HintProps>
23
+ >(({ children, asChild, showOn = defaultShowOn, className, ...rest }, ref) => {
17
24
  const puzzleContext = useChessPuzzleContext();
18
25
  if (!puzzleContext) {
19
26
  throw new Error("PuzzleContext not found");
20
27
  }
21
28
  const { onHint, status } = puzzleContext;
22
- const handleClick = () => {
29
+
30
+ const handleClick = React.useCallback(() => {
23
31
  onHint();
24
- };
32
+ }, [onHint]);
25
33
 
26
34
  if (!showOn.includes(status)) {
27
35
  return null;
28
36
  }
29
37
 
30
- if (asChild) {
31
- const child = React.Children.only(children);
32
- if (isClickableElement(child)) {
33
- return React.cloneElement(child, {
34
- onClick: handleClick,
35
- });
36
- } else {
37
- throw new Error("Change child must be a clickable element");
38
- }
39
- }
40
-
41
- return (
42
- <button type="button" onClick={handleClick}>
38
+ return asChild ? (
39
+ <Slot ref={ref} onClick={handleClick} className={className} {...rest}>
40
+ {children}
41
+ </Slot>
42
+ ) : (
43
+ <button
44
+ ref={ref as React.RefObject<HTMLButtonElement>}
45
+ type="button"
46
+ className={className}
47
+ onClick={handleClick}
48
+ {...rest}
49
+ >
43
50
  {children}
44
51
  </button>
45
52
  );
46
- };
53
+ });
54
+
55
+ Hint.displayName = "ChessPuzzle.Hint";
@@ -6,35 +6,41 @@ import {
6
6
  } from "@react-chess-tools/react-chess-game";
7
7
  import { getCustomSquareStyles, stringToMove } from "../../../utils";
8
8
  import { useChessPuzzleContext } from "../../..";
9
+ import { useChessPuzzleTheme } from "../../../theme/context";
9
10
 
10
- export interface PuzzleBoardProps
11
- extends React.ComponentProps<typeof ChessGame.Board> {}
12
- export const PuzzleBoard: React.FC<PuzzleBoardProps> = ({
13
- options = {},
14
- ...rest
15
- }) => {
16
- const puzzleContext = useChessPuzzleContext();
17
- const gameContext = useChessGameContext();
11
+ export interface PuzzleBoardProps extends React.ComponentProps<
12
+ typeof ChessGame.Board
13
+ > {}
18
14
 
19
- if (!puzzleContext) {
20
- throw new Error("PuzzleContext not found");
21
- }
22
- if (!gameContext) {
23
- throw new Error("ChessGameContext not found");
24
- }
15
+ export const PuzzleBoard = React.forwardRef<HTMLDivElement, PuzzleBoardProps>(
16
+ ({ options, ...rest }, ref) => {
17
+ const puzzleContext = useChessPuzzleContext();
18
+ const gameContext = useChessGameContext();
19
+ const theme = useChessPuzzleTheme();
25
20
 
26
- const { game } = gameContext;
27
- const { status, hint, isPlayerTurn, nextMove } = puzzleContext;
21
+ if (!puzzleContext) {
22
+ throw new Error("PuzzleContext not found");
23
+ }
24
+ if (!gameContext) {
25
+ throw new Error("ChessGameContext not found");
26
+ }
28
27
 
29
- const mergedOptions = deepMergeChessboardOptions(options, {
30
- squareStyles: getCustomSquareStyles(
31
- status,
32
- hint,
33
- isPlayerTurn,
34
- game,
35
- stringToMove(game, nextMove),
36
- ),
37
- });
28
+ const { game } = gameContext;
29
+ const { status, hint, isPlayerTurn, nextMove } = puzzleContext;
38
30
 
39
- return <ChessGame.Board {...rest} options={mergedOptions} />;
40
- };
31
+ const mergedOptions = deepMergeChessboardOptions(options || {}, {
32
+ squareStyles: getCustomSquareStyles(
33
+ status,
34
+ hint,
35
+ isPlayerTurn,
36
+ game,
37
+ stringToMove(game, nextMove),
38
+ theme,
39
+ ),
40
+ });
41
+
42
+ return <ChessGame.Board ref={ref} {...rest} options={mergedOptions} />;
43
+ },
44
+ );
45
+
46
+ PuzzleBoard.displayName = "ChessPuzzle.PuzzleBoard";
@@ -1,51 +1,71 @@
1
1
  import React from "react";
2
- import { isClickableElement, type Puzzle, type Status } from "../../../utils";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { type Puzzle, type Status } from "../../../utils";
3
4
  import { useChessPuzzleContext, type ChessPuzzleContextType } from "../../..";
4
5
 
5
- export interface ResetProps {
6
+ export interface ResetProps extends Omit<
7
+ React.ButtonHTMLAttributes<HTMLButtonElement>,
8
+ "onReset"
9
+ > {
6
10
  asChild?: boolean;
7
11
  puzzle?: Puzzle;
8
12
  onReset?: (puzzleContext: ChessPuzzleContextType) => void;
13
+ /**
14
+ * The puzzle statuses in which the reset button should be visible.
15
+ * @default ["failed", "solved"]
16
+ */
9
17
  showOn?: Status[];
10
18
  }
11
19
 
12
20
  const defaultShowOn: Status[] = ["failed", "solved"];
13
21
 
14
- export const Reset: React.FC<React.PropsWithChildren<ResetProps>> = ({
15
- children,
16
- asChild,
17
- puzzle,
18
- onReset,
19
- showOn = defaultShowOn,
20
- }) => {
21
- const puzzleContext = useChessPuzzleContext();
22
- if (!puzzleContext) {
23
- throw new Error("PuzzleContext not found");
24
- }
25
- const { changePuzzle, status } = puzzleContext;
26
- const handleClick = () => {
27
- changePuzzle(puzzle || puzzleContext.puzzle);
28
- onReset?.(puzzleContext);
29
- };
22
+ export const Reset = React.forwardRef<
23
+ HTMLElement,
24
+ React.PropsWithChildren<ResetProps>
25
+ >(
26
+ (
27
+ {
28
+ children,
29
+ asChild,
30
+ puzzle,
31
+ onReset,
32
+ showOn = defaultShowOn,
33
+ className,
34
+ ...rest
35
+ },
36
+ ref,
37
+ ) => {
38
+ const puzzleContext = useChessPuzzleContext();
39
+ if (!puzzleContext) {
40
+ throw new Error("PuzzleContext not found");
41
+ }
42
+ const { changePuzzle, puzzle: contextPuzzle, status } = puzzleContext;
30
43
 
31
- if (!showOn.includes(status)) {
32
- return null;
33
- }
44
+ const handleClick = React.useCallback(() => {
45
+ changePuzzle(puzzle || contextPuzzle);
46
+ onReset?.(puzzleContext);
47
+ }, [changePuzzle, puzzle, contextPuzzle, puzzleContext, onReset]);
34
48
 
35
- if (asChild) {
36
- const child = React.Children.only(children);
37
- if (isClickableElement(child)) {
38
- return React.cloneElement(child, {
39
- onClick: handleClick,
40
- });
41
- } else {
42
- throw new Error("Change child must be a clickable element");
49
+ if (!showOn.includes(status)) {
50
+ return null;
43
51
  }
44
- }
45
52
 
46
- return (
47
- <button type="button" onClick={handleClick}>
48
- {children}
49
- </button>
50
- );
51
- };
53
+ return asChild ? (
54
+ <Slot ref={ref} onClick={handleClick} className={className} {...rest}>
55
+ {children}
56
+ </Slot>
57
+ ) : (
58
+ <button
59
+ ref={ref as React.RefObject<HTMLButtonElement>}
60
+ type="button"
61
+ className={className}
62
+ onClick={handleClick}
63
+ {...rest}
64
+ >
65
+ {children}
66
+ </button>
67
+ );
68
+ },
69
+ );
70
+
71
+ Reset.displayName = "ChessPuzzle.Reset";
@@ -6,14 +6,26 @@ import {
6
6
  } from "../../../hooks/useChessPuzzle";
7
7
  import { ChessGame } from "@react-chess-tools/react-chess-game";
8
8
  import { ChessPuzzleContext } from "../../../hooks/useChessPuzzleContext";
9
+ import { PuzzleThemeProvider } from "../../../theme/context";
10
+ import { mergePuzzleTheme } from "../../../theme/utils";
11
+ import type { PartialChessPuzzleTheme } from "../../../theme/types";
9
12
 
10
13
  export interface RootProps {
11
14
  puzzle: Puzzle;
12
15
  onSolve?: (puzzleContext: ChessPuzzleContextType) => void;
13
16
  onFail?: (puzzleContext: ChessPuzzleContextType) => void;
17
+ /** Optional theme configuration. Supports partial themes - only override the colors you need. */
18
+ theme?: PartialChessPuzzleTheme;
14
19
  }
15
20
 
16
- const PuzzleRoot: React.FC<React.PropsWithChildren<RootProps>> = ({
21
+ interface PuzzleRootInnerProps {
22
+ puzzle: Puzzle;
23
+ onSolve?: (puzzleContext: ChessPuzzleContextType) => void;
24
+ onFail?: (puzzleContext: ChessPuzzleContextType) => void;
25
+ children: React.ReactNode;
26
+ }
27
+
28
+ const PuzzleRootInner: React.FC<PuzzleRootInnerProps> = ({
17
29
  puzzle,
18
30
  onSolve,
19
31
  onFail,
@@ -32,13 +44,25 @@ export const Root: React.FC<React.PropsWithChildren<RootProps>> = ({
32
44
  puzzle,
33
45
  onSolve,
34
46
  onFail,
47
+ theme,
35
48
  children,
36
49
  }) => {
50
+ // Merge partial theme with defaults
51
+ const mergedTheme = React.useMemo(() => mergePuzzleTheme(theme), [theme]);
52
+
37
53
  return (
38
- <ChessGame.Root fen={puzzle.fen} orientation={getOrientation(puzzle)}>
39
- <PuzzleRoot puzzle={puzzle} onSolve={onSolve} onFail={onFail}>
40
- {children}
41
- </PuzzleRoot>
54
+ <ChessGame.Root
55
+ fen={puzzle.fen}
56
+ orientation={getOrientation(puzzle)}
57
+ theme={mergedTheme}
58
+ >
59
+ <PuzzleThemeProvider theme={mergedTheme}>
60
+ <PuzzleRootInner puzzle={puzzle} onSolve={onSolve} onFail={onFail}>
61
+ {children}
62
+ </PuzzleRootInner>
63
+ </PuzzleThemeProvider>
42
64
  </ChessGame.Root>
43
65
  );
44
66
  };
67
+
68
+ Root.displayName = "ChessPuzzle.Root";