MostLime commited on
Commit
7021e31
·
verified ·
1 Parent(s): 9e8f8b5

Create frontend.html

Browse files
Files changed (1) hide show
  1. frontend.html +884 -0
frontend.html ADDED
@@ -0,0 +1,884 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
6
+ <title>LCM Chess</title>
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/chessboard-js/1.0.0/chessboard-1.0.0.min.css">
8
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/chess.js/0.10.3/chess.min.js"></script>
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/chessboard-js/1.0.0/chessboard-1.0.0.min.js"></script>
11
+ <style>
12
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
13
+
14
+ :root {
15
+ --bg: #1a1a1a;
16
+ --bg-side: #141210;
17
+ --bg-card: #1e1c1a;
18
+ --border: #2e2820;
19
+ --border2: #3a3530;
20
+ --text: #e8e0d0;
21
+ --dim: #8a7a6a;
22
+ --faint: #6a5a4a;
23
+ --accent: #b58863;
24
+ --accent-h: #c9956c;
25
+ --sq-light: #f0d9b5;
26
+ --sq-dark: #b58863;
27
+ --mono: 'Courier New', monospace;
28
+ }
29
+
30
+ html, body {
31
+ width: 100%; height: 100%;
32
+ background: var(--bg);
33
+ color: var(--text);
34
+ font-family: Georgia, serif;
35
+ overflow: hidden;
36
+ touch-action: manipulation;
37
+ -webkit-user-select: none;
38
+ user-select: none;
39
+ }
40
+
41
+ #app { display: flex; width: 100vw; height: 100vh; overflow: hidden; }
42
+
43
+ /* ── Sidebar ── */
44
+ #sidebar {
45
+ width: 220px; min-width: 220px;
46
+ background: var(--bg-side);
47
+ border-right: 1px solid var(--border);
48
+ display: flex; flex-direction: column;
49
+ padding: 18px 14px; gap: 16px;
50
+ overflow: hidden;
51
+ }
52
+
53
+ .lbl {
54
+ font-size: 0.68rem; color: var(--faint);
55
+ text-transform: uppercase; letter-spacing: 0.09em;
56
+ }
57
+
58
+ .side-btns { display: flex; gap: 4px; }
59
+ .side-btn {
60
+ flex: 1; padding: 7px 2px;
61
+ background: #252220; border: 1px solid var(--border2);
62
+ color: var(--dim); font-size: 0.78rem; border-radius: 4px;
63
+ cursor: pointer; transition: all 0.12s; font-family: Georgia, serif;
64
+ -webkit-tap-highlight-color: transparent;
65
+ }
66
+ .side-btn:hover { background: #2e2820; color: var(--text); }
67
+ .side-btn.active { background: var(--accent); border-color: var(--accent); color: #1a1a1a; font-weight: bold; }
68
+
69
+ .sg { display: flex; flex-direction: column; gap: 5px; }
70
+ .sr { display: flex; align-items: center; gap: 8px; }
71
+ input[type=range] { flex: 1; accent-color: var(--accent); cursor: pointer; }
72
+ .sv {
73
+ font-size: 0.76rem; color: var(--text);
74
+ font-family: var(--mono); min-width: 32px; text-align: right;
75
+ }
76
+
77
+ #new-game-btn {
78
+ width: 100%; padding: 9px;
79
+ background: var(--accent); border: none; border-radius: 6px;
80
+ color: #1a1a1a; font-size: 0.88rem; font-weight: bold;
81
+ font-family: Georgia, serif; cursor: pointer;
82
+ transition: background 0.12s;
83
+ -webkit-tap-highlight-color: transparent;
84
+ }
85
+ #new-game-btn:hover { background: var(--accent-h); }
86
+ #new-game-btn:disabled { background: #3a2e22; color: #6a5a4a; cursor: not-allowed; }
87
+
88
+ hr.div { border: none; border-top: 1px solid #252220; flex-shrink: 0; }
89
+
90
+ #hw { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 5px; overflow: hidden; }
91
+ #mh {
92
+ flex: 1; min-height: 0; overflow-y: auto;
93
+ font-family: var(--mono); font-size: 0.74rem; color: #c8b89a; line-height: 1.9;
94
+ }
95
+ #mh::-webkit-scrollbar { width: 3px; }
96
+ #mh::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
97
+
98
+ /* ── Main ── */
99
+ #main {
100
+ flex: 1; min-width: 0;
101
+ display: flex; flex-direction: column;
102
+ align-items: center; justify-content: center;
103
+ gap: 10px; padding: 16px; overflow: hidden;
104
+ }
105
+
106
+ #title { text-align: center; flex-shrink: 0; }
107
+ #title h1 { font-size: clamp(1.1rem, 3vw, 1.5rem); color: var(--sq-light); letter-spacing: 0.02em; }
108
+ #title p { font-size: clamp(0.68rem, 1.5vw, 0.8rem); color: var(--faint); font-style: italic; margin-top: 2px; }
109
+
110
+ #board-wrap { flex-shrink: 0; position: relative; }
111
+ #board { width: 100% !important; }
112
+
113
+ /* board square colors */
114
+ #board .white-1e1d7 { background-color: var(--sq-light) !important; }
115
+ #board .square-55d63 { cursor: default; }
116
+ #board.my-turn .square-55d63 { cursor: grab; }
117
+ #board .black-3c85d { background-color: var(--sq-dark) !important; }
118
+
119
+ /* highlights — inset box-shadow overlays the square without fighting chessboard.js bg */
120
+ .hl-from { box-shadow: inset 0 0 0 1000px rgba(205,210,106,0.65) !important; }
121
+ .hl-to { box-shadow: inset 0 0 0 1000px rgba(205,210,106,0.85) !important; }
122
+ .hl-sel { box-shadow: inset 0 0 0 1000px rgba(205,210,106,0.65) !important; }
123
+ .hl-check { box-shadow: inset 0 0 0 1000px rgba(210,50,50,0.65) !important; }
124
+ .hl-legal { background-image: radial-gradient(circle, rgba(0,0,0,0.22) 28%, transparent 30%) !important; }
125
+ .hl-capture { background-image: radial-gradient(circle, transparent 57%, rgba(0,0,0,0.22) 58%, rgba(0,0,0,0.22) 72%, transparent 74%) !important; }
126
+ /* drag hover — outline so it layers on top of box-shadow yellow fill */
127
+ .highlight-hover { outline: 4px solid rgba(255,255,255,0.5) !important; outline-offset: -4px !important; }
128
+
129
+ #status-bar {
130
+ flex-shrink: 0; width: 100%; max-width: 480px;
131
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
132
+ padding: 8px 14px;
133
+ font-family: var(--mono); font-size: clamp(0.7rem, 1.5vw, 0.8rem);
134
+ color: var(--text); text-align: center;
135
+ }
136
+
137
+ #thinking {
138
+ flex-shrink: 0; height: 18px;
139
+ display: none; align-items: center; gap: 6px;
140
+ font-size: 0.78rem; color: var(--accent); font-style: italic;
141
+ }
142
+ #thinking.on { display: flex; }
143
+ .dots span {
144
+ display: inline-block; width: 4px; height: 4px;
145
+ background: var(--accent); border-radius: 50%;
146
+ animation: bop 1.2s infinite;
147
+ }
148
+ .dots span:nth-child(2) { animation-delay: 0.2s; }
149
+ .dots span:nth-child(3) { animation-delay: 0.4s; }
150
+ @keyframes bop { 0%,80%,100%{transform:translateY(0)} 40%{transform:translateY(-5px)} }
151
+
152
+ /* ── Time controls ── */
153
+ .tc-btns { display: flex; gap: 4px; }
154
+ .tc-btn {
155
+ flex: 1; padding: 6px 2px;
156
+ background: #252220; border: 1px solid var(--border2);
157
+ color: var(--dim); font-size: 0.75rem; border-radius: 4px;
158
+ cursor: pointer; transition: all 0.12s; font-family: var(--mono);
159
+ -webkit-tap-highlight-color: transparent;
160
+ }
161
+ .tc-btn:hover { background: #2e2820; color: var(--text); }
162
+ .tc-btn.active { background: #2a3a2a; border-color: #4a7a4a; color: #8aba8a; font-weight: bold; }
163
+
164
+ /* ── Clock display (shows below board when active) ── */
165
+ #clocks {
166
+ display: none;
167
+ flex-shrink: 0;
168
+ width: 100%; max-width: 480px;
169
+ gap: 8px;
170
+ }
171
+ #clocks.on { display: flex; }
172
+ .clock-box {
173
+ flex: 1; padding: 8px 12px;
174
+ background: var(--bg-card); border: 1px solid var(--border);
175
+ border-radius: 6px; text-align: center;
176
+ font-family: var(--mono); font-size: 1.3rem; font-weight: bold;
177
+ color: var(--dim); transition: all 0.2s;
178
+ }
179
+ .clock-box.active { color: var(--sq-light); border-color: #5a5040; background: #252215; }
180
+ .clock-box.low { color: #e05050; border-color: #7a2020; background: #251515; animation: pulse-red 1s infinite; }
181
+ .clock-box .clk-lbl {
182
+ font-size: 0.62rem; color: var(--faint); letter-spacing: 0.08em;
183
+ text-transform: uppercase; display: block; margin-bottom: 2px;
184
+ }
185
+ @keyframes pulse-red { 0%,100%{opacity:1} 50%{opacity:0.65} }
186
+
187
+ /* ── PGN button ── */
188
+ #pgn-btn {
189
+ width: 100%; padding: 7px;
190
+ background: #1e1c1a; border: 1px solid var(--border2); border-radius: 5px;
191
+ color: var(--dim); font-size: 0.78rem; font-family: Georgia, serif;
192
+ cursor: pointer; transition: all 0.12s; margin-top: 2px;
193
+ -webkit-tap-highlight-color: transparent;
194
+ }
195
+ #pgn-btn:hover { background: #252220; color: var(--text); border-color: #5a4a3a; }
196
+ #pgn-btn:disabled { opacity: 0.35; cursor: not-allowed; }
197
+
198
+
199
+ /* ── Mobile ── */
200
+ #mob-fab {
201
+ display: none;
202
+ position: fixed; bottom: 14px; right: 14px;
203
+ width: 46px; height: 46px;
204
+ background: var(--accent); border: none; border-radius: 50%;
205
+ font-size: 1.3rem; cursor: pointer; z-index: 50;
206
+ align-items: center; justify-content: center;
207
+ box-shadow: 0 2px 12px rgba(0,0,0,0.5);
208
+ -webkit-tap-highlight-color: transparent;
209
+ }
210
+ #mob-overlay {
211
+ display: none; position: fixed; inset: 0;
212
+ background: rgba(0,0,0,0.55); z-index: 98; opacity: 0;
213
+ pointer-events: none; transition: opacity 0.25s;
214
+ }
215
+ #mob-overlay.on { opacity: 1; pointer-events: auto; }
216
+ #mob-panel {
217
+ display: none; position: fixed;
218
+ bottom: 0; left: 0; right: 0;
219
+ background: var(--bg-side);
220
+ border-top: 1px solid var(--border);
221
+ border-radius: 14px 14px 0 0;
222
+ padding: 18px 16px 30px;
223
+ flex-direction: column; gap: 16px;
224
+ z-index: 99;
225
+ transform: translateY(100%); transition: transform 0.28s ease;
226
+ }
227
+ #mob-panel.on { transform: translateY(0); }
228
+
229
+ @media (max-width: 680px) {
230
+ #sidebar { display: none; }
231
+ #mob-fab { display: flex; }
232
+ #mob-panel, #mob-overlay { display: flex; }
233
+ #mob-panel { display: flex; }
234
+ #main { padding: 10px; gap: 8px; }
235
+ }
236
+
237
+ /* ── Promotion modal ── */
238
+ #promo-modal {
239
+ display: none; position: fixed; inset: 0;
240
+ background: rgba(0,0,0,0.8); z-index: 200;
241
+ align-items: center; justify-content: center;
242
+ }
243
+ #promo-modal.on { display: flex; }
244
+ #promo-box {
245
+ background: #252220; border: 1px solid #4a4035;
246
+ border-radius: 10px; padding: 22px 28px; text-align: center;
247
+ }
248
+ #promo-box h3 { color: var(--sq-light); margin-bottom: 14px; font-size: 0.92rem; letter-spacing: 0.04em; }
249
+ .pb { display: flex; gap: 10px; }
250
+ .pb button {
251
+ width: 58px; height: 58px;
252
+ background: #1a1a1a; border: 1px solid #4a4035; border-radius: 6px;
253
+ cursor: pointer; font-size: 2rem;
254
+ display: flex; align-items: center; justify-content: center;
255
+ color: var(--text); transition: background 0.12s;
256
+ -webkit-tap-highlight-color: transparent;
257
+ }
258
+ .pb button:hover { background: var(--border2); }
259
+ </style>
260
+ </head>
261
+ <body>
262
+ <div id="app">
263
+
264
+ <!-- Desktop sidebar -->
265
+ <div id="sidebar">
266
+ <div>
267
+ <div class="lbl" style="margin-bottom:7px">Play as</div>
268
+ <div class="side-btns">
269
+ <button class="side-btn" data-side="white" onclick="setSide('white')">White</button>
270
+ <button class="side-btn active" data-side="random" onclick="setSide('random')">Rnd</button>
271
+ <button class="side-btn" data-side="black" onclick="setSide('black')">Black</button>
272
+ </div>
273
+ </div>
274
+ <div class="sg">
275
+ <div class="lbl">Temperature</div>
276
+ <div class="sr">
277
+ <input type="range" id="td" min="0.1" max="2.0" step="0.05" value="1.0" oninput="syncS('t',false)">
278
+ <span class="sv" id="tv">1.00</span>
279
+ </div>
280
+ </div>
281
+ <div class="sg">
282
+ <div class="lbl">Top-K <span style="color:#4a3a2a;font-size:0.66rem">(0=off)</span></div>
283
+ <div class="sr">
284
+ <input type="range" id="kd" min="0" max="20" step="1" value="0" oninput="syncS('k',false)">
285
+ <span class="sv" id="kv">0</span>
286
+ </div>
287
+ </div>
288
+ <div>
289
+ <div class="lbl" style="margin-bottom:7px">Time Control</div>
290
+ <div class="tc-btns">
291
+ <button class="tc-btn active" data-tc="0" onclick="setTC(0)">∞</button>
292
+ <button class="tc-btn" data-tc="60" onclick="setTC(60)">1m</button>
293
+ <button class="tc-btn" data-tc="300" onclick="setTC(300)">5m</button>
294
+ <button class="tc-btn" data-tc="600" onclick="setTC(600)">10m</button>
295
+ </div>
296
+ </div>
297
+ <button id="new-game-btn" onclick="newGame()">New Game</button>
298
+ <hr class="div">
299
+ <div id="hw">
300
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px"><span class="lbl">Move History</span><button id="pgn-btn" style="width:auto;padding:3px 8px;margin-top:0;font-size:0.68rem" onclick="copyPGN()" disabled>PGN ↓</button></div>
301
+ <div id="mh"></div>
302
+ </div>
303
+ </div>
304
+
305
+ <!-- Board -->
306
+ <div id="main">
307
+ <div id="title">
308
+ <h1>♟ Liquid Chess Model</h1>
309
+ <p>29.2M parameter hybrid transformer</p>
310
+ </div>
311
+ <div id="board-wrap"><div id="board"></div></div>
312
+ <div id="clocks">
313
+ <div class="clock-box" id="clk-top"><span class="clk-lbl" id="clk-top-lbl">Model</span><span id="clk-top-val">—</span></div>
314
+ <div class="clock-box active" id="clk-bot"><span class="clk-lbl" id="clk-bot-lbl">You</span><span id="clk-bot-val">—</span></div>
315
+ </div>
316
+ <div id="status-bar">Loading…</div>
317
+ </div>
318
+ </div>
319
+
320
+ <!-- Mobile FAB + panel -->
321
+ <button id="mob-fab" onclick="mobToggle()">⚙</button>
322
+ <div id="mob-overlay" onclick="mobClose()"></div>
323
+ <div id="mob-panel">
324
+ <div style="display:flex;justify-content:space-between;align-items:center">
325
+ <span style="font-size:0.9rem;color:var(--sq-light)">Settings</span>
326
+ <button onclick="mobClose()" style="background:none;border:none;color:var(--dim);font-size:1.3rem;cursor:pointer">✕</button>
327
+ </div>
328
+ <div>
329
+ <div class="lbl" style="margin-bottom:7px">Play as</div>
330
+ <div class="side-btns">
331
+ <button class="side-btn" data-side="white" onclick="setSide('white')">White</button>
332
+ <button class="side-btn active" data-side="random" onclick="setSide('random')">Rnd</button>
333
+ <button class="side-btn" data-side="black" onclick="setSide('black')">Black</button>
334
+ </div>
335
+ </div>
336
+ <div class="sg">
337
+ <div class="lbl">Temperature</div>
338
+ <div class="sr">
339
+ <input type="range" id="tm" min="0.1" max="2.0" step="0.05" value="1.0" oninput="syncS('t',true)">
340
+ <span class="sv" id="tvm">1.00</span>
341
+ </div>
342
+ </div>
343
+ <div class="sg">
344
+ <div class="lbl">Top-K <span style="color:#4a3a2a;font-size:0.66rem">(0=off)</span></div>
345
+ <div class="sr">
346
+ <input type="range" id="km" min="0" max="20" step="1" value="0" oninput="syncS('k',true)">
347
+ <span class="sv" id="kvm">0</span>
348
+ </div>
349
+ </div>
350
+ <div>
351
+ <div class="lbl" style="margin-bottom:7px">Time Control</div>
352
+ <div class="tc-btns">
353
+ <button class="tc-btn active" data-tc="0" onclick="setTC(0)">∞</button>
354
+ <button class="tc-btn" data-tc="60" onclick="setTC(60)">1m</button>
355
+ <button class="tc-btn" data-tc="300" onclick="setTC(300)">5m</button>
356
+ <button class="tc-btn" data-tc="600" onclick="setTC(600)">10m</button>
357
+ </div>
358
+ </div>
359
+ <button onclick="newGame();mobClose()" style="width:100%;padding:10px;background:var(--accent);border:none;border-radius:6px;color:#1a1a1a;font-size:0.9rem;font-weight:bold;font-family:Georgia,serif;cursor:pointer">New Game</button>
360
+ <button onclick="copyPGN();mobClose()" style="width:100%;padding:8px;background:#1e1c1a;border:1px solid var(--border2);border-radius:5px;color:var(--dim);font-size:0.82rem;font-family:Georgia,serif;cursor:pointer">Copy PGN</button>
361
+ </div>
362
+
363
+ <!-- Promotion modal -->
364
+ <div id="promo-modal">
365
+ <div id="promo-box">
366
+ <h3>Promote to…</h3>
367
+ <div class="pb">
368
+ <button onclick="pickPromo('q')">♛</button>
369
+ <button onclick="pickPromo('r')">♜</button>
370
+ <button onclick="pickPromo('b')">♝</button>
371
+ <button onclick="pickPromo('n')">♞</button>
372
+ </div>
373
+ </div>
374
+ </div>
375
+
376
+ <script>
377
+ // ── State ─────────────────────────────��────────────────────────────────────────
378
+ let game = new Chess();
379
+ let board = null;
380
+ let playerWhite = true;
381
+ let moveHistory = [];
382
+ let chosenSide = 'random';
383
+ let pendingPromo = null;
384
+ let gameOver = false;
385
+ let selectedSq = null;
386
+
387
+ const el = id => document.getElementById(id);
388
+
389
+ // ── Gradio SSE API ─────────────────────────────────────────────────────────────
390
+ // Gradio 4+ two-step: POST → get event_id, then GET stream → parse last data line
391
+ async function apiPost(endpoint, body) {
392
+ const r = await fetch('/api/' + endpoint, {
393
+ method: 'POST',
394
+ headers: { 'Content-Type': 'application/json' },
395
+ body: JSON.stringify(body),
396
+ });
397
+ if (!r.ok) throw new Error('Request failed: ' + r.status);
398
+ return r.json();
399
+ }
400
+
401
+ // ── Board size ─────────────────────────────────────────────────────────────────
402
+ function calcSize() {
403
+ const sideW = window.innerWidth > 680 ? 220 : 0;
404
+ const availW = window.innerWidth - sideW - 32;
405
+ const availH = window.innerHeight - 180;
406
+ return Math.floor(Math.min(availW, availH, 480));
407
+ }
408
+
409
+ // ── Board init ─────────────────────────────────────────────────────────────────
410
+ function initBoard(orientation) {
411
+ const size = calcSize();
412
+ el('board-wrap').style.width = size + 'px';
413
+
414
+ if (board) board.destroy();
415
+
416
+ board = Chessboard('board', {
417
+ position: game.fen(),
418
+ orientation: orientation,
419
+ draggable: true,
420
+ onDragStart: onDragStart,
421
+ onDrop: onDrop,
422
+ onSnapEnd: () => { board.position(game.fen()); applyHL(); },
423
+ onMouseoverSquare: onMouseOver,
424
+ onMouseoutSquare: onMouseOut,
425
+ onDragMove: onDragMoveBoard,
426
+ pieceTheme: 'https://chessboardjs.com/img/chesspieces/wikipedia/{piece}.png',
427
+ moveSpeed: 150,
428
+ snapbackSpeed: 200,
429
+ });
430
+ }
431
+
432
+ window.addEventListener('resize', () => {
433
+ if (!board) return;
434
+ const size = calcSize();
435
+ el('board-wrap').style.width = size + 'px';
436
+ board.resize();
437
+ });
438
+
439
+ // ── Highlight state ────────────────────────────────────────────────────────────
440
+ // Single source of truth. Call applyHL() after any state change or board.position().
441
+ let lastFrom = null;
442
+ let lastTo = null;
443
+ let isDrag = false;
444
+ let suppressNextClick = false;
445
+
446
+ function applyHL() {
447
+ clearHL();
448
+ if (lastFrom) hl(lastFrom, 'hl-from');
449
+ if (lastTo) hl(lastTo, 'hl-to');
450
+ if (selectedSq) {
451
+ hl(selectedSq, 'hl-sel');
452
+ legalDots(selectedSq);
453
+ }
454
+ if (game.in_check()) {
455
+ const t = game.turn();
456
+ for (let r = 1; r <= 8; r++) for (const f of 'abcdefgh') {
457
+ const sq = f + r, p = game.get(sq);
458
+ if (p && p.type === 'k' && p.color === t) hl(sq, 'hl-check');
459
+ }
460
+ }
461
+ }
462
+
463
+ // ── Drag ───────────────────────────────────────────────────────────────────────
464
+ function onDragStart(source, piece) {
465
+ if (!canMove()) return false;
466
+ if (!myPiece(piece)) return false;
467
+ isDrag = false;
468
+ // show legal dots for the piece being dragged
469
+ selectedSq = source;
470
+ applyHL();
471
+ return true;
472
+ }
473
+
474
+ function onDragMoveBoard(newSq) {
475
+ isDrag = true;
476
+ // hover border — outline, independent of box-shadow yellow
477
+ document.querySelectorAll('.highlight-hover').forEach(e => e.classList.remove('highlight-hover'));
478
+ if (newSq !== 'offboard') hl(newSq, 'highlight-hover');
479
+ }
480
+
481
+ function onDrop(source, target) {
482
+ document.querySelectorAll('.highlight-hover').forEach(e => e.classList.remove('highlight-hover'));
483
+ // Suppress the DOM click/touchend that always fires after mouseup/touchend,
484
+ // because the drag system (onDragStart/onDrop) already handled this interaction.
485
+ suppressNextClick = true;
486
+ setTimeout(() => { suppressNextClick = false; }, 100);
487
+
488
+ if (source === target || !isDrag) {
489
+ // User clicked without dragging — onDragStart already set selectedSq, keep it.
490
+ isDrag = false;
491
+ return 'snapback';
492
+ }
493
+ isDrag = false;
494
+ return tryMove(source, target) ? undefined : 'snapback';
495
+ }
496
+
497
+ function onMouseOver() {}
498
+ function onMouseOut() {}
499
+
500
+ // ── Click to move ──────────────────────────────────────────────────────────────
501
+ function onSquareClick(sq, pStr) {
502
+ if (!canMove()) return;
503
+
504
+ if (selectedSq) {
505
+ if (sq === selectedSq) {
506
+ selectedSq = null;
507
+ applyHL();
508
+ return;
509
+ }
510
+ // Reselect another own piece
511
+ const p = game.get(sq);
512
+ if (p && myPieceObj(p)) {
513
+ selectedSq = sq;
514
+ applyHL();
515
+ return;
516
+ }
517
+ // Attempt move
518
+ if (!tryMove(selectedSq, sq)) {
519
+ selectedSq = null;
520
+ applyHL();
521
+ }
522
+ return;
523
+ }
524
+
525
+ // Select piece
526
+ if (!pStr || !myPiece(pStr)) return;
527
+ selectedSq = sq;
528
+ applyHL();
529
+ }
530
+
531
+ // ── Move logic ─────────────────────────────────────────────────────────────────
532
+ function tryMove(from, to) {
533
+ const p = game.get(from);
534
+ const isPromo = p && p.type === 'p' &&
535
+ ((p.color === 'w' && to[1] === '8') || (p.color === 'b' && to[1] === '1'));
536
+
537
+ if (isPromo) {
538
+ // Check it's actually a legal move before showing modal
539
+ const test = game.move({ from, to, promotion: 'q' });
540
+ if (!test) return false;
541
+ game.undo();
542
+ pendingPromo = { from, to };
543
+ selectedSq = null;
544
+ clearHL();
545
+ el('promo-modal').classList.add('on');
546
+ return true;
547
+ }
548
+
549
+ const fenBefore = game.fen();
550
+ const mv = game.move({ from, to });
551
+ if (!mv) return false;
552
+
553
+ board.position(game.fen(), false);
554
+ hlLast(from, to);
555
+ sendMove(mv.from + mv.to + (mv.promotion || ''), fenBefore);
556
+ return true;
557
+ }
558
+
559
+ function pickPromo(piece) {
560
+ el('promo-modal').classList.remove('on');
561
+ if (!pendingPromo) return;
562
+ const { from, to } = pendingPromo;
563
+ pendingPromo = null;
564
+ const fenBefore = game.fen();
565
+ const mv = game.move({ from, to, promotion: piece });
566
+ if (!mv) return;
567
+ board.position(game.fen(), false);
568
+ hlLast(from, to);
569
+ sendMove(from + to + piece, fenBefore);
570
+ }
571
+
572
+ // ── Backend call ───────────────────────────────────────────────────────────────
573
+ async function sendMove(uci, fenBefore) {
574
+ stopClock();
575
+ startClock('model');
576
+ lockBoard(true);
577
+ try {
578
+ const r = await apiPost('make_move', {
579
+ move_uci: uci, fen: fenBefore, history: moveHistory,
580
+ temperature: parseFloat(getVal('t')),
581
+ top_k: parseInt(getVal('k')),
582
+ });
583
+ moveHistory = r.history;
584
+ game.load(r.fen);
585
+ board.position(r.fen, true);
586
+ if (r.model_move) { lastFrom = r.model_move.slice(0,2); lastTo = r.model_move.slice(2,4); }
587
+ else { lastFrom = null; lastTo = null; }
588
+ selectedSq = null;
589
+ applyHL();
590
+ updateHist(r.history_text);
591
+ setStatus(r.status);
592
+ gameOver = isOver(r.status);
593
+ lockBoard(gameOver);
594
+ updateTurnClass();
595
+ stopClock();
596
+ if (!gameOver) startClock('player');
597
+ } catch(e) {
598
+ setStatus('Error: ' + e.message);
599
+ game.undo();
600
+ board.position(game.fen());
601
+ lockBoard(false);
602
+ updateTurnClass();
603
+ stopClock();
604
+ startClock('player');
605
+ }
606
+ }
607
+
608
+ async function newGame() {
609
+ gameOver = false; selectedSq = null; clearHL();
610
+ el('new-game-btn').disabled = true;
611
+ setStatus('Starting…');
612
+ try {
613
+ const r = await apiPost('start_game', {
614
+ side: chosenSide,
615
+ temperature: parseFloat(getVal('t')),
616
+ top_k: parseInt(getVal('k')),
617
+ });
618
+ playerWhite = r.player_is_white;
619
+ moveHistory = r.history;
620
+ game = new Chess(r.fen);
621
+ // ← This is the fix: pass correct orientation based on actual assigned side
622
+ initBoard(playerWhite ? 'white' : 'black');
623
+ lastFrom = r.model_move ? r.model_move.slice(0,2) : null;
624
+ lastTo = r.model_move ? r.model_move.slice(2,4) : null;
625
+ selectedSq = null;
626
+ applyHL();
627
+ updateHist(r.history_text);
628
+ setStatus(r.status);
629
+ lockBoard(false);
630
+ resetClocks();
631
+ // if model went first (player is black), model already moved — start player clock
632
+ startClock('player');
633
+ el('pgn-btn') && (el('pgn-btn').disabled = false);
634
+ } catch(e) {
635
+ setStatus('Error: ' + e.message);
636
+ }
637
+ el('new-game-btn').disabled = false;
638
+ }
639
+
640
+ // ── Helpers ────────────────────────────────────────────────────────────────────
641
+ const canMove = () => !gameOver && !game.game_over() && (game.turn() === 'w') === playerWhite;
642
+ const myPiece = s => (playerWhite && s[0]==='w') || (!playerWhite && s[0]==='b');
643
+ const myPieceObj = p => (playerWhite && p.color==='w') || (!playerWhite && p.color==='b');
644
+ const isOver = s => /Checkmate|draw|Game over/i.test(s);
645
+
646
+ function getVal(name) {
647
+ const mob = window.innerWidth <= 680;
648
+ return mob ? el(name==='t'?'tm':'km').value : el(name==='t'?'td':'kd').value;
649
+ }
650
+
651
+ function syncS(name, fromMob) {
652
+ const d = el(name==='t'?'td':'kd');
653
+ const m = el(name==='t'?'tm':'km');
654
+ const dv = el(name==='t'?'tv':'kv');
655
+ const mv = el(name==='t'?'tvm':'kvm');
656
+ const fmt = name==='t' ? v => parseFloat(v).toFixed(2) : v => v;
657
+ if (fromMob) { d.value = m.value; }
658
+ else { m.value = d.value; }
659
+ const val = fromMob ? m.value : d.value;
660
+ dv.textContent = fmt(val);
661
+ mv.textContent = fmt(val);
662
+ }
663
+
664
+ function setSide(s) {
665
+ chosenSide = s;
666
+ document.querySelectorAll('.side-btn').forEach(b =>
667
+ b.classList.toggle('active', b.dataset.side === s));
668
+ }
669
+
670
+ const setStatus = msg => el('status-bar').textContent = msg;
671
+
672
+
673
+ function lockBoard(locked) {
674
+ el('board').style.pointerEvents = locked ? 'none' : '';
675
+ el('board').style.opacity = locked ? '0.75' : '';
676
+ updateTurnClass();
677
+ }
678
+
679
+ function updateTurnClass() {
680
+ const myTurn = !gameOver && (game.turn() === 'w') === playerWhite;
681
+ el('board').classList.toggle('my-turn', myTurn);
682
+ }
683
+
684
+ function updateHist(text) {
685
+ const btn = el('pgn-btn'); if (btn) btn.disabled = !text;
686
+ const d = el('mh');
687
+ d.innerHTML = (text||'').split('\n').filter(Boolean).map(l=>`<div>${l}</div>`).join('');
688
+ d.scrollTop = d.scrollHeight;
689
+ }
690
+
691
+ function hl(sq, cls) {
692
+ document.querySelectorAll(`[data-square="${sq}"]`).forEach(e => e.classList.add(cls));
693
+ }
694
+
695
+ function clearHL() {
696
+ ['hl-from','hl-to','hl-sel','hl-legal','hl-capture','hl-check'].forEach(cls =>
697
+ document.querySelectorAll('.'+cls).forEach(e => e.classList.remove(cls)));
698
+ }
699
+
700
+ function legalDots(sq) {
701
+ game.moves({ square: sq, verbose: true }).forEach(m => {
702
+ hl(m.to, game.get(m.to) ? 'hl-capture' : 'hl-legal');
703
+ });
704
+ }
705
+
706
+ function hlLast(from, to) {
707
+ selectedSq = null;
708
+ lastFrom = from;
709
+ lastTo = to;
710
+ applyHL();
711
+ }
712
+
713
+ function mobToggle() {
714
+ el('mob-panel').classList.toggle('on');
715
+ el('mob-overlay').classList.toggle('on');
716
+ }
717
+ function mobClose() {
718
+ el('mob-panel').classList.remove('on');
719
+ el('mob-overlay').classList.remove('on');
720
+ }
721
+
722
+
723
+ // ── Time controls ──────────────────────────────────────────────────────────────
724
+ let tcSecs = 0; // 0 = no limit
725
+ let playerTime = 0; // remaining seconds
726
+ let modelTime = 0;
727
+ let clockTick = null; // setInterval handle
728
+ let clockActive = null; // 'player' | 'model' | null
729
+
730
+ function setTC(secs) {
731
+ tcSecs = secs;
732
+ document.querySelectorAll('.tc-btn').forEach(b =>
733
+ b.classList.toggle('active', parseInt(b.dataset.tc) === secs));
734
+ }
735
+
736
+ function fmtTime(s) {
737
+ if (s <= 0) return '0:00';
738
+ const m = Math.floor(s / 60);
739
+ const sec = Math.floor(s % 60);
740
+ return m + ':' + String(sec).padStart(2, '0');
741
+ }
742
+
743
+ function startClock(who) {
744
+ // who: 'player' or 'model'
745
+ stopClock();
746
+ if (!tcSecs) return;
747
+ clockActive = who;
748
+ updateClockUI();
749
+ clockTick = setInterval(() => {
750
+ if (who === 'player') {
751
+ playerTime = Math.max(0, playerTime - 0.1);
752
+ if (playerTime <= 0) { flagFall('player'); return; }
753
+ } else {
754
+ modelTime = Math.max(0, modelTime - 0.1);
755
+ if (modelTime <= 0) { flagFall('model'); return; }
756
+ }
757
+ updateClockUI();
758
+ }, 100);
759
+ }
760
+
761
+ function stopClock() {
762
+ if (clockTick) { clearInterval(clockTick); clockTick = null; }
763
+ clockActive = null;
764
+ updateClockUI();
765
+ }
766
+
767
+ function flagFall(who) {
768
+ stopClock();
769
+ gameOver = true;
770
+ lockBoard(true);
771
+ setStatus(who === 'player' ? 'Time! Model wins on time.' : 'Time! You win on time.');
772
+ }
773
+
774
+ function resetClocks() {
775
+ stopClock();
776
+ playerTime = tcSecs;
777
+ modelTime = tcSecs;
778
+ updateClockUI();
779
+ el('clocks').classList.toggle('on', tcSecs > 0);
780
+ }
781
+
782
+ function updateClockUI() {
783
+ if (!tcSecs) return;
784
+ // Figure out which clock is on top (opponent) vs bottom (player)
785
+ const playerIsTop = !playerWhite; // board orientation: model is top if player=white
786
+ const topIsPlayer = playerIsTop;
787
+
788
+ const topSecs = topIsPlayer ? playerTime : modelTime;
789
+ const botSecs = topIsPlayer ? modelTime : playerTime;
790
+ const topWho = topIsPlayer ? 'You' : 'Model';
791
+ const botWho = topIsPlayer ? 'Model' : 'You';
792
+
793
+ el('clk-top-lbl').textContent = topWho;
794
+ el('clk-bot-lbl').textContent = botWho;
795
+ el('clk-top-val').textContent = fmtTime(topSecs);
796
+ el('clk-bot-val').textContent = fmtTime(botSecs);
797
+
798
+ const topActive = (clockActive === 'player' && topIsPlayer) || (clockActive === 'model' && !topIsPlayer);
799
+ el('clk-top').className = 'clock-box' + (topActive ? ' active' : '') + (topSecs < 10 && tcSecs ? ' low' : '');
800
+ el('clk-bot').className = 'clock-box' + (!topActive && clockActive ? ' active' : '') + (botSecs < 10 && tcSecs ? ' low' : '');
801
+ }
802
+
803
+ // ── PGN export ─────────────────────────────────────────────────────────────────
804
+ let pgnMoves = []; // SAN move list for PGN
805
+
806
+ function buildPGN() {
807
+ const now = new Date();
808
+ const date = now.toISOString().slice(0,10).replace(/-/g,'.');
809
+ const result = gameOver ? (
810
+ el('status-bar').textContent.includes('wins') ?
811
+ (el('status-bar').textContent.includes('Model') ? (playerWhite ? '0-1' : '1-0') : (playerWhite ? '1-0' : '0-1')) :
812
+ '1/2-1/2'
813
+ ) : '*';
814
+
815
+ const header = [
816
+ '[Event "LCM Chess"]',
817
+ '[Site "https://huggingface.co/spaces/MostLime/lcm-chess"]',
818
+ `[Date "${date}"]`,
819
+ `[White "${playerWhite ? 'You' : 'LCM'}"]`,
820
+ `[Black "${playerWhite ? 'LCM' : 'You'}"]`,
821
+ `[Result "${result}"]`,
822
+ `[TimeControl "${tcSecs ? tcSecs+'+0' : '-'}"]`,
823
+ ].join('\n');
824
+
825
+ // Build SAN from move history using chess.js
826
+ const tmp = new Chess();
827
+ const sans = [];
828
+ for (const uci of moveHistory) {
829
+ const from = uci.slice(0,2), to = uci.slice(2,4), promo = uci[4] || undefined;
830
+ const mv = tmp.move({ from, to, promotion: promo });
831
+ if (mv) sans.push(mv.san);
832
+ }
833
+
834
+ let body = '';
835
+ for (let i = 0; i < sans.length; i++) {
836
+ if (i % 2 === 0) body += (i/2+1) + '. ';
837
+ body += sans[i] + ' ';
838
+ }
839
+
840
+ return header + '\n\n' + body.trim() + ' ' + result;
841
+ }
842
+
843
+ function copyPGN() {
844
+ if (!moveHistory.length) return;
845
+ const pgn = buildPGN();
846
+ // Try native clipboard, fall back to download
847
+ if (navigator.clipboard && navigator.clipboard.writeText) {
848
+ navigator.clipboard.writeText(pgn).then(() => {
849
+ const btn = el('pgn-btn');
850
+ const orig = btn.textContent;
851
+ btn.textContent = 'Copied!';
852
+ setTimeout(() => btn.textContent = orig, 1500);
853
+ }).catch(() => downloadPGN(pgn));
854
+ } else {
855
+ downloadPGN(pgn);
856
+ }
857
+ }
858
+
859
+ function downloadPGN(pgn) {
860
+ const a = document.createElement('a');
861
+ a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(pgn);
862
+ a.download = 'lcm-chess.pgn';
863
+ a.click();
864
+ }
865
+
866
+ // Delegated click/touch on board-wrap — survives board.destroy()/reinit
867
+ function handleBoardTap(e) {
868
+ if (e.type === 'touchend') e.preventDefault();
869
+ // If the drag system just handled this same physical interaction, skip
870
+ if (suppressNextClick) { suppressNextClick = false; return; }
871
+ const sqEl = (e.changedTouches ? e.changedTouches[0].target : e.target).closest('[data-square]');
872
+ if (!sqEl) return;
873
+ const sq = sqEl.getAttribute('data-square');
874
+ const piece = game.get(sq);
875
+ const pStr = piece ? (piece.color === 'w' ? 'w' : 'b') + piece.type.toUpperCase() : null;
876
+ onSquareClick(sq, pStr);
877
+ }
878
+ el('board-wrap').addEventListener('click', handleBoardTap);
879
+ el('board-wrap').addEventListener('touchend', handleBoardTap, { passive: false });
880
+
881
+ window.addEventListener('load', () => { initBoard('white'); newGame(); });
882
+ </script>
883
+ </body>
884
+ </html>