diff --git a/src/main.jsx b/src/main.jsx
index 8f98b78..b87adb1 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -1,14 +1,19 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { Chess } from 'chess.js';
-import { Brain, ChevronRight, CircleHelp, FlipVertical2, Lightbulb, LoaderCircle, Pause, Play, RotateCcw, Settings, SkipBack, SkipForward, Sparkles, X } from 'lucide-react';
+import { Brain, ChevronRight, CircleHelp, FlipVertical2, LayoutGrid, Lightbulb, LoaderCircle, Pause, Play, RotateCcw, Settings, SkipBack, SkipForward, Sparkles, Trash2, X } from 'lucide-react';
import './styles.css';
+import { buildFen, positionFromGame } from './position.js';
const PIECES = { p: '♟', n: '♞', b: '♝', r: '♜', q: '♛', k: '♚', P: '♟', N: '♞', B: '♝', R: '♜', Q: '♛', K: '♚' };
const FILES = ['a','b','c','d','e','f','g','h'];
const RANKS = ['8','7','6','5','4','3','2','1'];
const START_FEN = new Chess().fen();
const defaultSettings = { ollamaUrl: 'http://host.docker.internal:11434', model: 'qwen3:8b', depth: 16 };
+const POSITION_PRESETS = {
+ middlegame: 'r2q1rk1/pp2bppp/2npbn2/2p1p3/4P3/2NP1N2/PPQ1BPPP/R1B2RK1 w - - 0 10',
+ endgame: '8/5pk1/3p2p1/1p1P4/1P2P3/5P2/5KPP/8 w - - 0 32',
+};
function scoreLabel(score) {
if (!score) return '—';
@@ -90,6 +95,68 @@ function SettingsPanel({ value, onSave, onClose }) {
;
}
+function PositionEditor({ fen, onApply, onClose }) {
+ const [position, setPosition] = useState(() => positionFromGame(new Chess(fen)));
+ const [turn, setTurn] = useState(() => new Chess(fen).turn());
+ const [tool, setTool] = useState('wq');
+ const [editorError, setEditorError] = useState('');
+ const palette = ['wk', 'wq', 'wr', 'wb', 'wn', 'wp', 'bk', 'bq', 'br', 'bb', 'bn', 'bp'];
+
+ function loadPreset(nextFen) {
+ const next = new Chess(nextFen);
+ setPosition(positionFromGame(next)); setTurn(next.turn()); setEditorError('');
+ }
+
+ function place(square) {
+ setPosition((current) => {
+ const next = { ...current };
+ if (tool === 'erase') delete next[square];
+ else next[square] = { color: tool[0], type: tool[1] };
+ return next;
+ });
+ setEditorError('');
+ }
+
+ function applyPosition() {
+ const pieces = Object.values(position);
+ if (pieces.filter((p) => p.type === 'k' && p.color === 'w').length !== 1 || pieces.filter((p) => p.type === 'k' && p.color === 'b').length !== 1) {
+ setEditorError('Placez exactement un roi blanc et un roi noir.'); return;
+ }
+ const candidate = buildFen(position, turn);
+ try { new Chess(candidate); onApply(candidate); }
+ catch { setEditorError('Position illégale. Vérifiez rois, pions et mises en échec.'); }
+ }
+
+ return
e.target === e.currentTarget && onClose()}>
+
+ LABORATOIRE
Composer une position
+
+
+ {RANKS.flatMap((rank) => FILES.map((file) => {
+ const square = `${file}${rank}`;
+ const piece = position[square];
+ const dark = (FILES.indexOf(file) + Number(rank)) % 2 === 1;
+ const code = piece ? (piece.color === 'w' ? piece.type.toUpperCase() : piece.type) : null;
+ return ;
+ }))}
+
+
+
PIÈCES{palette.map((item) => {
+ const code = item[0] === 'w' ? item[1].toUpperCase() : item[1];
+ return ;
+ })}
+
+
TRAIT
+
EXEMPLES
+
+
+ {editorError || 'Choisissez côté au trait. Stockfish analysera position dès validation.'}
+
+
;
+}
+
function App() {
const [fen, setFen] = useState(START_FEN);
const [moves, setMoves] = useState([]);
@@ -103,6 +170,7 @@ function App() {
const [explaining, setExplaining] = useState(false);
const [showHint, setShowHint] = useState(true);
const [showSettings, setShowSettings] = useState(false);
+ const [showPositionEditor, setShowPositionEditor] = useState(false);
const [previewLine, setPreviewLine] = useState(null);
const [previewStep, setPreviewStep] = useState(0);
const [previewPlaying, setPreviewPlaying] = useState(false);
@@ -197,6 +265,10 @@ function App() {
function reset() { setFen(START_FEN); setMoves([]); setHistory([]); setSelected(null); setAnalysis([]); setExplanation(''); }
+ function loadComposedPosition(nextFen) {
+ setFen(nextFen); setMoves([]); setHistory([]); setSelected(null); setAnalysis([]); setExplanation(''); setShowPositionEditor(false); setShowHint(true);
+ }
+
async function explain() {
if (!analysis.length) return;
setExplaining(true); setError('');
@@ -232,6 +304,7 @@ function App() {
+
@@ -266,6 +339,7 @@ function App() {
{showSettings && setShowSettings(false)} onSave={(next) => { setSettings(next); localStorage.setItem('atelier64-settings', JSON.stringify(next)); setShowSettings(false); }}/>}
+ {showPositionEditor && setShowPositionEditor(false)} onApply={loadComposedPosition}/>}
;
}
diff --git a/src/position.js b/src/position.js
new file mode 100644
index 0000000..b9d4a5a
--- /dev/null
+++ b/src/position.js
@@ -0,0 +1,28 @@
+const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
+const RANKS = ['8', '7', '6', '5', '4', '3', '2', '1'];
+
+export function positionFromGame(game) {
+ const position = {};
+ for (const rank of RANKS) {
+ for (const file of FILES) {
+ const piece = game.get(`${file}${rank}`);
+ if (piece) position[`${file}${rank}`] = { type: piece.type, color: piece.color };
+ }
+ }
+ return position;
+}
+
+export function buildFen(position, turn = 'w') {
+ const rows = RANKS.map((rank) => {
+ let row = '';
+ let empty = 0;
+ for (const file of FILES) {
+ const piece = position[`${file}${rank}`];
+ if (!piece) { empty += 1; continue; }
+ if (empty) { row += empty; empty = 0; }
+ row += piece.color === 'w' ? piece.type.toUpperCase() : piece.type;
+ }
+ return row + (empty || '');
+ });
+ return `${rows.join('/')} ${turn} - - 0 1`;
+}
diff --git a/src/position.test.js b/src/position.test.js
new file mode 100644
index 0000000..97ba1cc
--- /dev/null
+++ b/src/position.test.js
@@ -0,0 +1,8 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { buildFen } from './position.js';
+
+test('construit FEN depuis position composée', () => {
+ const position = { e1: { type: 'k', color: 'w' }, e8: { type: 'k', color: 'b' }, d4: { type: 'q', color: 'w' } };
+ assert.equal(buildFen(position, 'b'), '4k3/8/8/8/3Q4/8/8/4K3 b - - 0 1');
+});
diff --git a/src/styles.css b/src/styles.css
index e05d807..1093085 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -41,6 +41,8 @@ h1,h2 { font-family:'Playfair Display',serif;margin:4px 0 0;font-weight:600; } h
.moves-panel{margin-top:27px}.move-list{max-height:155px;overflow:auto;padding-top:6px}.move-row{display:grid;grid-template-columns:35px 1fr 1fr;padding:6px 2px;font-size:12px}.move-row span{font:10px 'DM Mono';color:#8d887d}.move-row b{font-weight:600}.moves-empty{font:italic 14px 'Playfair Display';color:#8a857a;display:block;padding:16px 0}.error-banner{font-size:11px;padding:11px 13px;margin-top:15px;background:#b94c3d;color:white}
footer { border-top:1px solid var(--line);padding:18px 4vw;display:flex;justify-content:space-between;color:#8d877b;font:9px 'DM Mono';letter-spacing:.12em; }
.modal-backdrop{position:fixed;inset:0;z-index:20;background:rgba(13,15,12,.68);backdrop-filter:blur(8px);display:grid;place-items:center;padding:20px}.settings-panel{width:min(540px,100%);background:var(--cream);box-shadow:0 30px 90px rgba(0,0,0,.35);padding:29px}.panel-heading{display:flex;justify-content:space-between;align-items:start;margin-bottom:24px}.icon-button{border:0;background:transparent;cursor:pointer}.settings-panel label{display:block;font-size:12px;font-weight:700;margin-top:18px}.settings-panel label>span{float:right;font:11px 'DM Mono';color:var(--copper)}.settings-panel input:not([type=range]){display:block;width:100%;margin-top:7px;background:transparent;border:0;border-bottom:1px solid #8e897f;padding:10px 2px;font:13px 'DM Mono';outline:none}.settings-panel input:focus{border-color:var(--copper)}.settings-panel input[type=range]{display:block;width:100%;accent-color:var(--copper);margin-top:12px}.field-note{font-size:10px;color:#777268;margin:7px 0}.recommendation{margin-top:19px;padding:13px;background:#e7dfcf;display:flex;gap:10px;align-items:flex-start;font-size:11px;line-height:1.5}.recommendation svg{flex:0 0 auto;color:var(--copper)}.settings-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:27px}.settings-actions button{padding:11px 16px;border:1px solid var(--ink);font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;cursor:pointer;display:flex;gap:7px;align-items:center}.settings-actions .primary{background:var(--ink);color:var(--cream)}.settings-actions .secondary{background:transparent}.connection-status{font-size:11px;text-align:right}.connection-status.success{color:#52704e}.connection-status.error{color:#b24b3c}.spin{animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
+.position-editor{width:min(900px,96vw);max-height:94vh;overflow:auto;background:var(--cream);box-shadow:0 30px 90px rgba(0,0,0,.38);padding:28px}.position-editor .panel-heading{margin-bottom:20px}.editor-layout{display:grid;grid-template-columns:minmax(330px,1fr) 280px;gap:28px}.editor-board{width:100%;aspect-ratio:1;display:grid;grid-template-columns:repeat(8,minmax(0,1fr));grid-template-rows:repeat(8,minmax(0,1fr));border:6px solid var(--ink);box-shadow:0 15px 30px rgba(25,22,17,.17)}.editor-board>button{border:0;padding:0;min-width:0;min-height:0;display:grid;place-items:center;cursor:pointer}.editor-board>button.light{background:#d8d0bc}.editor-board>button.dark{background:#67725e}.editor-board .piece{font-size:clamp(27px,5vw,54px);pointer-events:none}.editor-tools{display:flex;flex-direction:column;gap:19px}.tool-label{display:block;font:9px 'DM Mono';letter-spacing:.16em;color:#777267;margin-bottom:8px}.piece-palette{display:grid;grid-template-columns:repeat(6,1fr);border:1px solid var(--line)}.piece-palette button{height:45px;border:0;border-right:1px solid var(--line);border-bottom:1px solid var(--line);font:29px 'Times New Roman';cursor:pointer;display:grid;place-items:center}.piece-palette button:nth-child(6n){border-right:0}.piece-palette button.active{box-shadow:inset 0 0 0 3px var(--copper);position:relative;z-index:1}.palette-white{background:#68735f;color:#f8f0dd;-webkit-text-stroke:1px #20231d}.palette-black{background:#d8d0bc;color:#171914}.erase-tool{align-self:flex-start;border:1px solid var(--line);background:transparent;padding:8px 11px;display:flex;align-items:center;gap:7px;cursor:pointer;font-size:10px;text-transform:uppercase;font-weight:800}.erase-tool.active{background:var(--copper);color:white;border-color:var(--copper)}.turn-choice{display:grid;grid-template-columns:1fr 1fr;gap:7px}.turn-choice button{border:1px solid var(--line);background:transparent;padding:10px;display:flex;align-items:center;justify-content:center;gap:8px;cursor:pointer;font-size:11px}.turn-choice button.active{border-color:var(--ink);background:#e6dece;font-weight:800}.turn-choice i{flex:0 0 auto}.preset-list{display:flex;flex-direction:column}.preset-list button{border:0;border-bottom:1px solid var(--line);background:transparent;text-align:left;padding:9px 1px;cursor:pointer;font:12px 'Playfair Display',serif}.preset-list button:hover{color:var(--copper);padding-left:5px}.editor-footer{display:flex;align-items:center;justify-content:flex-end;gap:20px;margin-top:22px;border-top:1px solid var(--line);padding-top:17px}.editor-footer p{margin:0 auto 0 0;font-size:11px;color:#716c62}.editor-footer .primary{border:1px solid var(--ink);background:var(--ink);color:var(--cream);padding:12px 16px;text-transform:uppercase;font-size:10px;font-weight:800;letter-spacing:.07em;cursor:pointer}
@media(max-width:980px){header{padding:0 22px}.header-status{display:none}main{grid-template-columns:1fr;width:min(720px,94vw)}.board-shell{width:100%}.coach-column{border-left:0;border-top:1px solid var(--line);padding:32px 0 0}.game-meta{padding-left:32px}}
-@media(max-width:560px){header{height:64px}.brand small{display:none}.settings-button{font-size:0;padding:10px}.settings-button svg{width:19px;height:19px}.game-meta{padding-left:27px}.turn-chip{display:none}main{padding-top:22px}.board-area{gap:9px}.eval-bar{width:16px}.board-shell{border-width:4px}.piece{font-size:11vw}.board-actions{padding-left:25px;gap:14px}.board-actions button{font-size:0}.board-actions button svg{width:20px;height:20px}.board-actions button:last-child{font-size:10px}footer{gap:20px;line-height:1.5}.settings-panel{padding:22px}}
+@media(max-width:720px){.position-editor{padding:19px}.editor-layout{grid-template-columns:1fr}.editor-board{max-width:500px;margin:auto}.editor-tools{display:grid;grid-template-columns:1fr 1fr}.editor-tools>div:first-child{grid-column:1/-1}.editor-footer{align-items:flex-end}}
+@media(max-width:560px){header{height:64px}.brand small{display:none}.settings-button{font-size:0;padding:10px}.settings-button svg{width:19px;height:19px}.game-meta{padding-left:27px}.turn-chip{display:none}main{padding-top:22px}.board-area{gap:9px}.eval-bar{width:16px}.board-shell{border-width:4px}.piece{font-size:11vw}.board-actions{padding-left:25px;gap:14px}.board-actions button{font-size:0}.board-actions button svg{width:20px;height:20px}.board-actions button:last-child{font-size:10px}footer{gap:20px;line-height:1.5}.settings-panel{padding:22px}.editor-tools{grid-template-columns:1fr}.editor-tools>div:first-child{grid-column:auto}.editor-footer{display:block}.editor-footer .primary{width:100%;margin-top:10px}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}