@scriptonita/chess-football-ui 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -159,7 +159,7 @@ type GameTranslator = (key: string, values?: Record<string, unknown>) => string;
159
159
  * grep -rhoE "t\('[a-zA-Z0-9_.]+'" src/components | sed -E "s/t\('//;s/'//" | sort -u
160
160
  * (the dynamic `pieces.*` / `eventToast.*` families are derived separately below).
161
161
  */
162
- declare const STATIC_I18N_KEYS: readonly ["actionPointsAriaLabel", "actionPointsShort", "endTurn", "endTurnConfirm", "endTurnConfirmDescription", "endTurnConfirmYes", "endTurnKeepPlaying", "history.empty", "history.goal", "history.interception", "history.move", "history.offside", "history.pass", "history.tackle", "history.title", "history.turn", "history.viewAll", "move", "offsideWarning", "pass", "selectedPiece.alreadyMoved", "selectedPiece.atSquare", "selectedPiece.empty", "selectedPiece.hasBall", "teamBlack", "teamWhite", "waitingRival", "yourTurn"];
162
+ declare const STATIC_I18N_KEYS: readonly ["actionPointsAriaLabel", "actionPointsShort", "ballHolder", "endTurn", "endTurnConfirm", "endTurnConfirmDescription", "endTurnConfirmYes", "endTurnKeepPlaying", "history.empty", "history.goal", "history.interception", "history.move", "history.offside", "history.pass", "history.tackle", "history.title", "history.turn", "history.viewAll", "move", "offsideWarning", "pass", "selectedPiece.alreadyMoved", "selectedPiece.atSquare", "selectedPiece.empty", "selectedPiece.hasBall", "shortcuts.arrows", "shortcuts.buttonLabel", "shortcuts.cancel", "shortcuts.move", "shortcuts.pass", "shortcuts.select", "shortcuts.title", "teamBlack", "teamWhite", "waitingRival", "yourTurn"];
163
163
  /** `pieces.*` — one key per short and long piece label the engine exposes (move-history, selected-piece-detail). */
164
164
  type PieceI18nKey = `pieces.${PieceShortKey | PieceLongKey}`;
165
165
  /** `eventToast.*` — one key per toastable event (see `ToastEvent` in `event-toast.tsx`). */
package/dist/index.d.ts CHANGED
@@ -159,7 +159,7 @@ type GameTranslator = (key: string, values?: Record<string, unknown>) => string;
159
159
  * grep -rhoE "t\('[a-zA-Z0-9_.]+'" src/components | sed -E "s/t\('//;s/'//" | sort -u
160
160
  * (the dynamic `pieces.*` / `eventToast.*` families are derived separately below).
161
161
  */
162
- declare const STATIC_I18N_KEYS: readonly ["actionPointsAriaLabel", "actionPointsShort", "endTurn", "endTurnConfirm", "endTurnConfirmDescription", "endTurnConfirmYes", "endTurnKeepPlaying", "history.empty", "history.goal", "history.interception", "history.move", "history.offside", "history.pass", "history.tackle", "history.title", "history.turn", "history.viewAll", "move", "offsideWarning", "pass", "selectedPiece.alreadyMoved", "selectedPiece.atSquare", "selectedPiece.empty", "selectedPiece.hasBall", "teamBlack", "teamWhite", "waitingRival", "yourTurn"];
162
+ declare const STATIC_I18N_KEYS: readonly ["actionPointsAriaLabel", "actionPointsShort", "ballHolder", "endTurn", "endTurnConfirm", "endTurnConfirmDescription", "endTurnConfirmYes", "endTurnKeepPlaying", "history.empty", "history.goal", "history.interception", "history.move", "history.offside", "history.pass", "history.tackle", "history.title", "history.turn", "history.viewAll", "move", "offsideWarning", "pass", "selectedPiece.alreadyMoved", "selectedPiece.atSquare", "selectedPiece.empty", "selectedPiece.hasBall", "shortcuts.arrows", "shortcuts.buttonLabel", "shortcuts.cancel", "shortcuts.move", "shortcuts.pass", "shortcuts.select", "shortcuts.title", "teamBlack", "teamWhite", "waitingRival", "yourTurn"];
163
163
  /** `pieces.*` — one key per short and long piece label the engine exposes (move-history, selected-piece-detail). */
164
164
  type PieceI18nKey = `pieces.${PieceShortKey | PieceLongKey}`;
165
165
  /** `eventToast.*` — one key per toastable event (see `ToastEvent` in `event-toast.tsx`). */
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { useGameStore, getInitialBoardState } from './chunk-N24LGLLO.js';
2
- import { SHORT_KEY, LONG_KEY, getValidMoves, getValidPasses, isInEnemyArea, RANK_LABELS, FILE_LABELS, squareName } from '@scriptonita/chess-football-engine';
2
+ import { SHORT_KEY, LONG_KEY, getValidMoves, getValidPasses, isInEnemyArea, getPath, RANK_LABELS, FILE_LABELS, squareName } from '@scriptonita/chess-football-engine';
3
3
  import { clsx } from 'clsx';
4
4
  import { twMerge } from 'tailwind-merge';
5
- import { MotionConfig, AnimatePresence, motion } from 'framer-motion';
6
- import { Crown, Zap, Flag, ShieldQuestion, ChessKnight, ChessBishop, ChessRook, ChessQueen, ChessKing, X, Move } from 'lucide-react';
5
+ import { MotionConfig, useReducedMotion, AnimatePresence, motion } from 'framer-motion';
6
+ import { HelpCircle, Crown, Zap, Flag, ShieldQuestion, ChessKnight, ChessBishop, ChessRook, ChessQueen, ChessKing, X, Move } from 'lucide-react';
7
7
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
8
8
  import * as React from 'react';
9
9
  import { createContext, useId, useContext, useState, useRef, useEffect, useMemo } from 'react';
@@ -128,6 +128,7 @@ var Football = ({ className }) => {
128
128
  var STATIC_I18N_KEYS = [
129
129
  "actionPointsAriaLabel",
130
130
  "actionPointsShort",
131
+ "ballHolder",
131
132
  "endTurn",
132
133
  "endTurnConfirm",
133
134
  "endTurnConfirmDescription",
@@ -150,6 +151,13 @@ var STATIC_I18N_KEYS = [
150
151
  "selectedPiece.atSquare",
151
152
  "selectedPiece.empty",
152
153
  "selectedPiece.hasBall",
154
+ "shortcuts.arrows",
155
+ "shortcuts.buttonLabel",
156
+ "shortcuts.cancel",
157
+ "shortcuts.move",
158
+ "shortcuts.pass",
159
+ "shortcuts.select",
160
+ "shortcuts.title",
153
161
  "teamBlack",
154
162
  "teamWhite",
155
163
  "waitingRival",
@@ -181,6 +189,7 @@ var ROWS = 12;
181
189
  var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
182
190
  function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
183
191
  const t = useGameT();
192
+ const prefersReducedMotion = useReducedMotion();
184
193
  const {
185
194
  boardState,
186
195
  selectedPieceId,
@@ -191,11 +200,62 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
191
200
  } = useGameStore();
192
201
  const [cursor, setCursor] = useState(null);
193
202
  const [disambiguateAt, setDisambiguateAt] = useState(null);
203
+ const [shortcutsOpen, setShortcutsOpen] = useState(false);
204
+ const shortcutsRef = useRef(null);
205
+ useEffect(() => {
206
+ if (!shortcutsOpen) return;
207
+ const onPointerDown = (e) => {
208
+ if (shortcutsRef.current && !shortcutsRef.current.contains(e.target)) {
209
+ setShortcutsOpen(false);
210
+ }
211
+ };
212
+ document.addEventListener("mousedown", onPointerDown);
213
+ return () => document.removeEventListener("mousedown", onPointerDown);
214
+ }, [shortcutsOpen]);
215
+ const [invalidClickAt, setInvalidClickAt] = useState(null);
216
+ const invalidClickNonceRef = useRef(0);
217
+ const triggerInvalidClickShake = (x, y) => {
218
+ invalidClickNonceRef.current += 1;
219
+ setInvalidClickAt({ x, y, nonce: invalidClickNonceRef.current });
220
+ };
221
+ const [hoveredPassAt, setHoveredPassAt] = useState(null);
222
+ useEffect(() => {
223
+ if (!invalidClickAt) return;
224
+ const id = setTimeout(() => setInvalidClickAt(null), 100);
225
+ return () => clearTimeout(id);
226
+ }, [invalidClickAt]);
194
227
  const prevBallRef = useRef(boardState?.ball);
195
228
  const prevBall = prevBallRef.current;
196
229
  useEffect(() => {
197
230
  prevBallRef.current = boardState?.ball;
198
231
  });
232
+ const prevTurnRef = useRef(boardState?.turn);
233
+ const [replay, setReplay] = useState(null);
234
+ useEffect(() => {
235
+ const prevTurn = prevTurnRef.current;
236
+ prevTurnRef.current = boardState?.turn;
237
+ if (!boardState || prevTurn === boardState.turn) return;
238
+ if (boardState.turn !== userSide) {
239
+ setReplay(null);
240
+ return;
241
+ }
242
+ if (prefersReducedMotion || userSide === null) return;
243
+ const lastEntry = boardState.moveHistory.at(-1);
244
+ if (!lastEntry) return;
245
+ const steps = boardState.moveHistory.filter(
246
+ (m) => m.turnNumber === lastEntry.turnNumber && m.pieceSide !== userSide
247
+ );
248
+ if (steps.length > 1) setReplay({ steps, index: 0 });
249
+ }, [boardState, userSide, prefersReducedMotion]);
250
+ useEffect(() => {
251
+ if (!replay) return;
252
+ if (replay.index >= replay.steps.length - 1) {
253
+ const id2 = setTimeout(() => setReplay(null), 350);
254
+ return () => clearTimeout(id2);
255
+ }
256
+ const id = setTimeout(() => setReplay((r) => r ? { ...r, index: r.index + 1 } : null), 350);
257
+ return () => clearTimeout(id);
258
+ }, [replay]);
199
259
  const validMoves = useMemo(() => {
200
260
  if (!boardState) return [];
201
261
  const sp = boardState.pieces.find((p) => p.id === selectedPieceId);
@@ -213,6 +273,7 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
213
273
  return [];
214
274
  }, [boardState, selectedPieceId]);
215
275
  if (!boardState) return null;
276
+ const ballHolderPiece = boardState.pieces.find((p) => p.id === boardState.ball.holderId) ?? null;
216
277
  const lastMove = boardState.lastMove;
217
278
  const ball = boardState.ball;
218
279
  const ballMoved = !!prevBall && (prevBall.pos.x !== ball.pos.x || prevBall.pos.y !== ball.pos.y);
@@ -233,7 +294,11 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
233
294
  }
234
295
  const ballCarrier = ball.holderId ? boardState.pieces.find((p) => p.id === ball.holderId) ?? null : null;
235
296
  const isOffsideRisk = !!ballCarrier && ballCarrier.type !== "king" && userSide !== null && ballCarrier.side === userSide && boardState.turn === userSide && isInEnemyArea(ballCarrier.pos, ballCarrier.side);
297
+ const passCarrier = boardState.pieces.find((p) => p.id === selectedPieceId && boardState.ball.holderId === p.id) ?? null;
298
+ const trajectoryPath = hoveredPassAt && passCarrier ? passCarrier.type === "knight" ? [hoveredPassAt] : getPath(passCarrier.pos, hoveredPassAt) : null;
299
+ const trajectoryIntercepted = !!(trajectoryPath && passCarrier && trajectoryPath.some((sq) => boardState.pieces.some((p) => p.pos.x === sq.x && p.pos.y === sq.y && p.side !== passCarrier.side)));
236
300
  const handleSquareClick = (x, y) => {
301
+ if (replay) return;
237
302
  setCursor(null);
238
303
  const isValidMove = validMoves.some((m) => m.x === x && m.y === y);
239
304
  const isValidPass = validPasses.some((p) => p.x === x && p.y === y);
@@ -244,15 +309,21 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
244
309
  if (isValidMove && selectedPieceId) {
245
310
  movePiece(selectedPieceId, { x, y });
246
311
  setDisambiguateAt(null);
312
+ setInvalidClickAt(null);
247
313
  } else if (isValidPass) {
248
314
  passBall({ x, y });
249
315
  setDisambiguateAt(null);
316
+ setInvalidClickAt(null);
250
317
  } else {
318
+ if (selectedPieceId) {
319
+ triggerInvalidClickShake(x, y);
320
+ }
251
321
  setDisambiguateAt(null);
252
322
  }
253
323
  };
254
324
  const handlePieceClick = (pieceId, x, y, e) => {
255
325
  e.stopPropagation();
326
+ if (replay) return;
256
327
  const pieceAt = boardState.pieces.find((p) => p.id === pieceId);
257
328
  if (!pieceAt) return;
258
329
  const isValidMove = validMoves.some((m) => m.x === x && m.y === y);
@@ -266,6 +337,10 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
266
337
  setSelectedPieceId(pieceAt.id);
267
338
  }
268
339
  setDisambiguateAt(null);
340
+ setInvalidClickAt(null);
341
+ } else if (selectedPieceId) {
342
+ triggerInvalidClickShake(x, y);
343
+ setDisambiguateAt(null);
269
344
  }
270
345
  };
271
346
  const defaultCursor = () => {
@@ -282,7 +357,7 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
282
357
  }
283
358
  };
284
359
  const handleKeyDown = (e) => {
285
- if (!keyboardNav) return;
360
+ if (!keyboardNav || replay) return;
286
361
  const step = (dx, dy) => {
287
362
  e.preventDefault();
288
363
  setCursor((c) => {
@@ -326,7 +401,9 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
326
401
  }
327
402
  break;
328
403
  case "Escape":
329
- if (disambiguateAt) {
404
+ if (shortcutsOpen) {
405
+ setShortcutsOpen(false);
406
+ } else if (disambiguateAt) {
330
407
  setDisambiguateAt(null);
331
408
  } else {
332
409
  setSelectedPieceId(null);
@@ -344,8 +421,9 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
344
421
  const isValidMove = validMoves.some((m) => m.x === x && m.y === y);
345
422
  const isValidPass = validPasses.some((p) => p.x === x && p.y === y);
346
423
  const isAmbiguous = isValidMove && isValidPass;
347
- const isLastMoveOrigin = lastMove?.from && lastMove.from.x === x && lastMove.from.y === y;
348
- const isLastMoveDest = lastMove?.to && lastMove.to.x === x && lastMove.to.y === y;
424
+ const displayedLastMove = replay ? replay.steps[replay.index] : lastMove;
425
+ const isLastMoveOrigin = displayedLastMove?.from && displayedLastMove.from.x === x && displayedLastMove.from.y === y;
426
+ const isLastMoveDest = displayedLastMove?.to && displayedLastMove.to.x === x && displayedLastMove.to.y === y;
349
427
  const isLastMove = isLastMoveOrigin || isLastMoveDest;
350
428
  const isEnemyGoalArea = userSide !== null && (x >= 2 && x <= 6) && (userSide === "white" ? y >= 10 && y <= 11 : y >= 0 && y <= 1);
351
429
  const pieceAt = boardState.pieces.find((p) => p.pos.x === x && p.pos.y === y);
@@ -356,6 +434,10 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
356
434
  "div",
357
435
  {
358
436
  onClick: () => handleSquareClick(x, y),
437
+ onMouseEnter: () => {
438
+ if (isValidPass) setHoveredPassAt({ x, y });
439
+ },
440
+ onMouseLeave: () => setHoveredPassAt(null),
359
441
  className: cn(
360
442
  "relative aspect-square w-full flex items-center justify-center cursor-pointer",
361
443
  isEven ? "bg-field-green-1" : "bg-field-green-2",
@@ -428,7 +510,7 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
428
510
  }
429
511
  return squares;
430
512
  };
431
- const board = /* @__PURE__ */ jsx(
513
+ const board = /* @__PURE__ */ jsxs(
432
514
  "div",
433
515
  {
434
516
  className: cn(
@@ -445,104 +527,220 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
445
527
  "aria-label": "Game board \u2014 arrow keys to move the cursor, Enter to act",
446
528
  onKeyDown: handleKeyDown
447
529
  } : {},
448
- children: /* @__PURE__ */ jsxs("div", { className: "relative", style: { containerType: "inline-size" }, children: [
449
- /* @__PURE__ */ jsx("div", { className: "grid grid-cols-9 gap-[1px]", children: renderSquares() }),
450
- /* @__PURE__ */ jsxs(
451
- "svg",
452
- {
453
- viewBox: "0 0 9 12",
454
- preserveAspectRatio: "none",
455
- className: "absolute inset-0 w-full h-full pointer-events-none",
456
- "aria-hidden": "true",
457
- children: [
458
- /* @__PURE__ */ jsx("rect", { x: "2", y: "0", width: "5", height: "2", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
459
- /* @__PURE__ */ jsx("rect", { x: "2", y: "10", width: "5", height: "2", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
460
- /* @__PURE__ */ jsx("line", { x1: "0", y1: "6", x2: "9", y2: "6", stroke: "white", strokeOpacity: "0.35", strokeWidth: "0.05" }),
461
- /* @__PURE__ */ jsx("circle", { cx: "4.5", cy: "6", r: "1.5", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
462
- /* @__PURE__ */ jsx("circle", { cx: "4.5", cy: "6", r: "0.12", fill: "white", fillOpacity: "0.5" }),
463
- /* @__PURE__ */ jsx("circle", { cx: "4.5", cy: "1.5", r: "0.1", fill: "white", fillOpacity: "0.5" }),
464
- /* @__PURE__ */ jsx("circle", { cx: "4.5", cy: "10.5", r: "0.1", fill: "white", fillOpacity: "0.5" }),
465
- /* @__PURE__ */ jsx("path", { d: "M 3.086,2 A 1.5,1.5 0 0 0 5.914,2", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
466
- /* @__PURE__ */ jsx("path", { d: "M 3.086,10 A 1.5,1.5 0 0 1 5.914,10", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" })
467
- ]
468
- }
469
- ),
470
- /* @__PURE__ */ jsxs("div", { className: "absolute inset-0 pointer-events-none", children: [
471
- /* @__PURE__ */ jsx(AnimatePresence, { mode: "sync", children: boardState.pieces.map((piece) => {
472
- const isPickupCarrier = pickupCarrierId === piece.id;
473
- const pieceTransition = isPickupCarrier ? { duration: PICKUP_DURATION, ease: "linear" } : { type: "spring", stiffness: 200, damping: 25, mass: 1 };
474
- return /* @__PURE__ */ jsx(
475
- motion.div,
530
+ children: [
531
+ /* @__PURE__ */ jsxs("div", { className: "hidden md:flex items-center justify-between gap-2 px-2.5 py-1", children: [
532
+ /* @__PURE__ */ jsx("div", { className: "flex items-center gap-1.5 min-h-[1.75rem]", "aria-live": "polite", children: ballHolderPiece && /* @__PURE__ */ jsxs(Fragment, { children: [
533
+ /* @__PURE__ */ jsxs(
534
+ "div",
476
535
  {
477
- layout: true,
478
- initial: false,
479
- animate: {
480
- left: `${piece.pos.x / COLS * 100}%`,
481
- top: `${(ROWS - 1 - piece.pos.y) / ROWS * 100}%`
482
- },
483
- transition: pieceTransition,
484
- style: {
485
- position: "absolute",
486
- width: `${100 / COLS}%`,
487
- height: `${100 / ROWS}%`
488
- },
489
- className: "flex items-center justify-center pointer-events-auto",
490
- children: /* @__PURE__ */ jsx(
491
- GamePiece,
536
+ style: ballHolderPiece.side === "white" ? WHITE_PIECE_STYLE : BLACK_PIECE_STYLE,
537
+ className: "relative w-7 h-7 flex items-center justify-center rounded-full shrink-0",
538
+ children: [
539
+ /* @__PURE__ */ jsx(PieceIcon, { type: ballHolderPiece.type, side: ballHolderPiece.side }),
540
+ /* @__PURE__ */ jsx("div", { className: "absolute -bottom-1 -right-1 w-3.5 h-3.5 flex items-center justify-center rounded-full bg-bg-secondary border border-border-subtle", children: /* @__PURE__ */ jsx("div", { className: "w-2.5 h-2.5", children: /* @__PURE__ */ jsx(Football, {}) }) })
541
+ ]
542
+ }
543
+ ),
544
+ /* @__PURE__ */ jsxs("span", { className: "font-inter text-[11px] text-fg-secondary", children: [
545
+ t("ballHolder"),
546
+ ": ",
547
+ t(`pieces.${ballHolderPiece.type}`)
548
+ ] })
549
+ ] }) }),
550
+ keyboardNav && /* @__PURE__ */ jsxs(
551
+ "div",
552
+ {
553
+ ref: shortcutsRef,
554
+ className: "relative",
555
+ onKeyDown: (e) => e.stopPropagation(),
556
+ children: [
557
+ /* @__PURE__ */ jsx(
558
+ "button",
492
559
  {
493
- piece,
494
- isSelected: selectedPieceId === piece.id,
495
- hasBall: boardState.ball.holderId === piece.id,
496
- onClick: (e) => handlePieceClick(piece.id, piece.pos.x, piece.pos.y, e)
560
+ type: "button",
561
+ onClick: (e) => {
562
+ e.stopPropagation();
563
+ setShortcutsOpen((o) => !o);
564
+ },
565
+ "aria-label": t("shortcuts.buttonLabel"),
566
+ "aria-expanded": shortcutsOpen,
567
+ className: "w-8 h-8 flex items-center justify-center rounded-full bg-bg-secondary/80 border border-border-subtle text-fg-muted hover:text-fg-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-green",
568
+ children: /* @__PURE__ */ jsx(HelpCircle, { size: 16, strokeWidth: 2, "aria-hidden": "true" })
569
+ }
570
+ ),
571
+ shortcutsOpen && /* @__PURE__ */ jsxs(
572
+ "div",
573
+ {
574
+ className: "absolute top-full right-0 mt-1 z-50 w-max max-w-[220px] bg-bg-secondary border border-border-subtle rounded-md shadow-xl p-2.5 pointer-events-auto",
575
+ onClick: (e) => e.stopPropagation(),
576
+ children: [
577
+ /* @__PURE__ */ jsx("p", { className: "font-inter text-[11px] font-semibold text-fg-primary mb-1.5", children: t("shortcuts.title") }),
578
+ /* @__PURE__ */ jsxs("ul", { className: "font-mono text-[10px] text-fg-secondary leading-relaxed", children: [
579
+ /* @__PURE__ */ jsxs("li", { children: [
580
+ /* @__PURE__ */ jsx("kbd", { className: "text-fg-primary", children: "\u2191\u2193\u2190\u2192" }),
581
+ " ",
582
+ t("shortcuts.arrows")
583
+ ] }),
584
+ /* @__PURE__ */ jsxs("li", { children: [
585
+ /* @__PURE__ */ jsx("kbd", { className: "text-fg-primary", children: "Enter" }),
586
+ " ",
587
+ t("shortcuts.select")
588
+ ] }),
589
+ /* @__PURE__ */ jsxs("li", { children: [
590
+ /* @__PURE__ */ jsx("kbd", { className: "text-fg-primary", children: "M" }),
591
+ " ",
592
+ t("shortcuts.move")
593
+ ] }),
594
+ /* @__PURE__ */ jsxs("li", { children: [
595
+ /* @__PURE__ */ jsx("kbd", { className: "text-fg-primary", children: "P" }),
596
+ " ",
597
+ t("shortcuts.pass")
598
+ ] }),
599
+ /* @__PURE__ */ jsxs("li", { children: [
600
+ /* @__PURE__ */ jsx("kbd", { className: "text-fg-primary", children: "Esc" }),
601
+ " ",
602
+ t("shortcuts.cancel")
603
+ ] })
604
+ ] })
605
+ ]
497
606
  }
498
607
  )
499
- },
500
- piece.id
501
- );
502
- }) }),
503
- (() => {
504
- const targetX = ball.pos.x / COLS * 100;
505
- const targetY = (ROWS - 1 - ball.pos.y) / ROWS * 100;
506
- let animate;
507
- let transition;
508
- if (isPickupOnMove && prevBall) {
509
- const prevX = prevBall.pos.x / COLS * 100;
510
- const prevY = (ROWS - 1 - prevBall.pos.y) / ROWS * 100;
511
- const pulseStart = Math.max(0.05, pickupProportion - 0.06);
512
- animate = {
513
- left: [`${prevX}%`, `${prevX}%`, `${prevX}%`, `${targetX}%`],
514
- top: [`${prevY}%`, `${prevY}%`, `${prevY}%`, `${targetY}%`],
515
- scale: [1, 1, 1.35, 1]
516
- };
517
- transition = { duration: PICKUP_DURATION, times: [0, pulseStart, pickupProportion, 1], ease: "linear" };
518
- } else {
519
- animate = { left: `${targetX}%`, top: `${targetY}%`, scale: 1 };
520
- transition = { type: "spring", stiffness: 300, damping: 30, mass: 0.5 };
608
+ ]
521
609
  }
522
- return /* @__PURE__ */ jsx(
610
+ )
611
+ ] }),
612
+ /* @__PURE__ */ jsxs("div", { className: "relative", style: { containerType: "inline-size" }, children: [
613
+ /* @__PURE__ */ jsx("div", { className: "grid grid-cols-9 gap-[1px]", children: renderSquares() }),
614
+ /* @__PURE__ */ jsxs(
615
+ "svg",
616
+ {
617
+ viewBox: "0 0 9 12",
618
+ preserveAspectRatio: "none",
619
+ className: "absolute inset-0 w-full h-full pointer-events-none",
620
+ "aria-hidden": "true",
621
+ children: [
622
+ /* @__PURE__ */ jsx("rect", { x: "2", y: "0", width: "5", height: "2", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
623
+ /* @__PURE__ */ jsx("rect", { x: "2", y: "10", width: "5", height: "2", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
624
+ /* @__PURE__ */ jsx("line", { x1: "0", y1: "6", x2: "9", y2: "6", stroke: "white", strokeOpacity: "0.35", strokeWidth: "0.05" }),
625
+ /* @__PURE__ */ jsx("circle", { cx: "4.5", cy: "6", r: "1.5", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
626
+ /* @__PURE__ */ jsx("circle", { cx: "4.5", cy: "6", r: "0.12", fill: "white", fillOpacity: "0.5" }),
627
+ /* @__PURE__ */ jsx("circle", { cx: "4.5", cy: "1.5", r: "0.1", fill: "white", fillOpacity: "0.5" }),
628
+ /* @__PURE__ */ jsx("circle", { cx: "4.5", cy: "10.5", r: "0.1", fill: "white", fillOpacity: "0.5" }),
629
+ /* @__PURE__ */ jsx("path", { d: "M 3.086,2 A 1.5,1.5 0 0 0 5.914,2", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
630
+ /* @__PURE__ */ jsx("path", { d: "M 3.086,10 A 1.5,1.5 0 0 1 5.914,10", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
631
+ hoveredPassAt && passCarrier && /* @__PURE__ */ jsx(
632
+ "line",
633
+ {
634
+ "data-testid": "pass-trajectory-line",
635
+ x1: passCarrier.pos.x + 0.5,
636
+ y1: ROWS - 1 - passCarrier.pos.y + 0.5,
637
+ x2: hoveredPassAt.x + 0.5,
638
+ y2: ROWS - 1 - hoveredPassAt.y + 0.5,
639
+ stroke: trajectoryIntercepted ? "var(--danger)" : "var(--pass-highlight)",
640
+ strokeWidth: "0.08",
641
+ strokeDasharray: "0.15 0.1",
642
+ strokeLinecap: "round"
643
+ }
644
+ )
645
+ ]
646
+ }
647
+ ),
648
+ /* @__PURE__ */ jsxs("div", { className: "absolute inset-0 pointer-events-none", children: [
649
+ /* @__PURE__ */ jsx(AnimatePresence, { mode: "sync", children: boardState.pieces.map((piece) => {
650
+ const isPickupCarrier = pickupCarrierId === piece.id;
651
+ const pieceTransition = isPickupCarrier ? { duration: PICKUP_DURATION, ease: "linear" } : { type: "spring", stiffness: 200, damping: 25, mass: 1 };
652
+ return /* @__PURE__ */ jsx(
653
+ motion.div,
654
+ {
655
+ layout: true,
656
+ initial: false,
657
+ animate: {
658
+ left: `${piece.pos.x / COLS * 100}%`,
659
+ top: `${(ROWS - 1 - piece.pos.y) / ROWS * 100}%`
660
+ },
661
+ transition: pieceTransition,
662
+ style: {
663
+ position: "absolute",
664
+ width: `${100 / COLS}%`,
665
+ height: `${100 / ROWS}%`
666
+ },
667
+ className: "flex items-center justify-center pointer-events-auto",
668
+ children: /* @__PURE__ */ jsx(
669
+ GamePiece,
670
+ {
671
+ piece,
672
+ isSelected: selectedPieceId === piece.id,
673
+ hasBall: boardState.ball.holderId === piece.id,
674
+ onClick: (e) => handlePieceClick(piece.id, piece.pos.x, piece.pos.y, e)
675
+ }
676
+ )
677
+ },
678
+ piece.id
679
+ );
680
+ }) }),
681
+ (() => {
682
+ const targetX = ball.pos.x / COLS * 100;
683
+ const targetY = (ROWS - 1 - ball.pos.y) / ROWS * 100;
684
+ let animate;
685
+ let transition;
686
+ if (isPickupOnMove && prevBall) {
687
+ const prevX = prevBall.pos.x / COLS * 100;
688
+ const prevY = (ROWS - 1 - prevBall.pos.y) / ROWS * 100;
689
+ const pulseStart = Math.max(0.05, pickupProportion - 0.06);
690
+ animate = {
691
+ left: [`${prevX}%`, `${prevX}%`, `${prevX}%`, `${targetX}%`],
692
+ top: [`${prevY}%`, `${prevY}%`, `${prevY}%`, `${targetY}%`],
693
+ scale: [1, 1, 1.35, 1]
694
+ };
695
+ transition = { duration: PICKUP_DURATION, times: [0, pulseStart, pickupProportion, 1], ease: "linear" };
696
+ } else {
697
+ animate = { left: `${targetX}%`, top: `${targetY}%`, scale: 1 };
698
+ transition = { type: "spring", stiffness: 300, damping: 30, mass: 0.5 };
699
+ }
700
+ return /* @__PURE__ */ jsx(
701
+ motion.div,
702
+ {
703
+ initial: false,
704
+ animate,
705
+ transition,
706
+ style: {
707
+ position: "absolute",
708
+ width: `${100 / COLS}%`,
709
+ height: `${100 / ROWS}%`,
710
+ zIndex: 50
711
+ },
712
+ className: "flex items-center justify-center pointer-events-none",
713
+ children: /* @__PURE__ */ jsx("div", { className: "w-[60%] h-[60%]", children: /* @__PURE__ */ jsx(Football, {}) })
714
+ },
715
+ "ball"
716
+ );
717
+ })(),
718
+ invalidClickAt && /* @__PURE__ */ jsx(
523
719
  motion.div,
524
720
  {
721
+ "data-testid": "invalid-action-shake",
722
+ "aria-hidden": "true",
525
723
  initial: false,
526
- animate,
527
- transition,
724
+ animate: prefersReducedMotion ? { opacity: [0.9, 0] } : { x: [0, -3, 3, -3, 3, 0] },
725
+ transition: { duration: 0.08 },
528
726
  style: {
529
727
  position: "absolute",
728
+ left: `${invalidClickAt.x / COLS * 100}%`,
729
+ top: `${(ROWS - 1 - invalidClickAt.y) / ROWS * 100}%`,
530
730
  width: `${100 / COLS}%`,
531
- height: `${100 / ROWS}%`,
532
- zIndex: 50
731
+ height: `${100 / ROWS}%`
533
732
  },
534
- className: "flex items-center justify-center pointer-events-none",
535
- children: /* @__PURE__ */ jsx("div", { className: "w-[60%] h-[60%]", children: /* @__PURE__ */ jsx(Football, {}) })
733
+ className: "ring-2 ring-inset ring-danger/70 rounded-sm"
536
734
  },
537
- "ball"
538
- );
539
- })()
540
- ] }),
541
- isOffsideRisk && /* @__PURE__ */ jsxs("div", { className: "absolute bottom-2 left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 px-3 py-1.5 bg-warning/20 border border-warning/50 rounded-full font-inter text-xs font-semibold text-warning pointer-events-none whitespace-nowrap backdrop-blur-sm", children: [
542
- "\u26A0 ",
543
- t("offsideWarning")
735
+ invalidClickAt.nonce
736
+ )
737
+ ] }),
738
+ isOffsideRisk && /* @__PURE__ */ jsxs("div", { className: "absolute bottom-2 left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 px-3 py-1.5 bg-warning/20 border border-warning/50 rounded-full font-inter text-xs font-semibold text-warning pointer-events-none whitespace-nowrap backdrop-blur-sm", children: [
739
+ "\u26A0 ",
740
+ t("offsideWarning")
741
+ ] })
544
742
  ] })
545
- ] })
743
+ ]
546
744
  }
547
745
  );
548
746
  if (!showCoordinates) return board;
@@ -587,31 +785,50 @@ function AvatarFallback({ className, children }) {
587
785
  }
588
786
  function ActionPoints({ total, remaining, size = 20, className, kingMustRelease }) {
589
787
  const t = useGameT();
788
+ const prevRemainingRef = useRef(remaining);
789
+ const [spentRange, setSpentRange] = useState(null);
790
+ useEffect(() => {
791
+ const prev = prevRemainingRef.current;
792
+ prevRemainingRef.current = remaining;
793
+ if (remaining < prev) {
794
+ setSpentRange([remaining, prev - 1]);
795
+ const id = setTimeout(() => setSpentRange(null), 300);
796
+ return () => clearTimeout(id);
797
+ }
798
+ setSpentRange(null);
799
+ }, [remaining]);
590
800
  return /* @__PURE__ */ jsx("div", { className: cn("flex items-center gap-1.5", className), "aria-label": t("actionPointsAriaLabel", { remaining, total }), children: Array.from({ length: total }).map((_, i) => {
591
801
  const isActive = i < remaining;
592
802
  const showCrown = kingMustRelease && i === total - 1;
593
- if (showCrown) {
594
- return /* @__PURE__ */ jsx(
595
- Crown,
596
- {
597
- size,
598
- strokeWidth: 2,
599
- className: "text-yellow-400 fill-yellow-400 transition-colors duration-150",
600
- "aria-hidden": "true"
601
- },
602
- i
603
- );
604
- }
803
+ const justSpent = !!spentRange && i >= spentRange[0] && i <= spentRange[1];
605
804
  return /* @__PURE__ */ jsx(
606
- Zap,
805
+ motion.span,
607
806
  {
608
- size,
609
- strokeWidth: 2,
610
- className: cn(
611
- "transition-colors duration-150",
612
- isActive ? "text-accent-green fill-accent-green" : "text-fg-muted fill-none"
613
- ),
614
- "aria-hidden": "true"
807
+ "data-testid": `ap-pip-${i}`,
808
+ "data-spent": justSpent || void 0,
809
+ className: "inline-flex",
810
+ animate: justSpent ? { scale: [1.5, 1] } : void 0,
811
+ transition: { duration: 0.3 },
812
+ children: showCrown ? /* @__PURE__ */ jsx(
813
+ Crown,
814
+ {
815
+ size,
816
+ strokeWidth: 2,
817
+ className: "text-yellow-400 fill-yellow-400 transition-colors duration-150",
818
+ "aria-hidden": "true"
819
+ }
820
+ ) : /* @__PURE__ */ jsx(
821
+ Zap,
822
+ {
823
+ size,
824
+ strokeWidth: 2,
825
+ className: cn(
826
+ "transition-colors duration-150",
827
+ isActive ? "text-accent-green fill-accent-green" : "text-fg-muted fill-none"
828
+ ),
829
+ "aria-hidden": "true"
830
+ }
831
+ )
615
832
  },
616
833
  i
617
834
  );