@scriptonita/chess-football-ui 0.7.0 → 0.8.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/README.md CHANGED
@@ -35,6 +35,27 @@ npm install @scriptonita/chess-football-ui
35
35
  (`bg-bg-surface`, `text-accent-green`, …). Each app must define those tokens and include this
36
36
  package's files in its Tailwind `content`/`@source` scan so the classes are generated.
37
37
 
38
+ ## Host app contract
39
+
40
+ Two things the package cannot enforce at build time and every consumer must check:
41
+
42
+ **1. Translation coverage.** `GAME_I18N_KEYS` is the manifest of every `game`-namespace key
43
+ the components read. Assert it in each app's test suite — without this, a key added here
44
+ surfaces as raw text on the pitch (`⚠ game.offsideWarning`) instead of failing CI:
45
+
46
+ ```ts
47
+ import { GAME_I18N_KEYS } from '@scriptonita/chess-football-ui'
48
+ import es from './messages/es.json'
49
+
50
+ const missing = GAME_I18N_KEYS.filter(k => !k.split('.').reduce<any>((o, p) => o?.[p], es.game))
51
+ expect(missing).toEqual([])
52
+ ```
53
+
54
+ **2. Design tokens.** Components emit Tailwind classes bound to tokens the app defines. A
55
+ token that is missing fails silently — the rule simply does not apply. The board's
56
+ `--last-move-highlight`, `--move-highlight`, `--pass-highlight`, `--accent-green`,
57
+ `--accent-green-light` and `--accent-green-dark` are all required.
58
+
38
59
  ## Peer dependencies
39
60
 
40
61
  `react`, `react-dom`, `framer-motion`, `lucide-react`.
package/dist/index.cjs CHANGED
@@ -190,6 +190,7 @@ var STATIC_I18N_KEYS = [
190
190
  "actionPointsAriaLabel",
191
191
  "actionPointsShort",
192
192
  "ballHolder",
193
+ "boardAriaLabel",
193
194
  "endTurn",
194
195
  "endTurnConfirm",
195
196
  "endTurnConfirmDescription",
@@ -203,11 +204,13 @@ var STATIC_I18N_KEYS = [
203
204
  "history.pass",
204
205
  "history.tackle",
205
206
  "history.title",
207
+ "history.close",
206
208
  "history.turn",
207
209
  "history.viewAll",
208
210
  "move",
209
211
  "offsideWarning",
210
212
  "pass",
213
+ "scoreAriaLabel",
211
214
  "selectedPiece.alreadyMoved",
212
215
  "selectedPiece.atSquare",
213
216
  "selectedPiece.empty",
@@ -219,6 +222,14 @@ var STATIC_I18N_KEYS = [
219
222
  "shortcuts.pass",
220
223
  "shortcuts.select",
221
224
  "shortcuts.title",
225
+ "square.empty",
226
+ "square.emptyWithBall",
227
+ "square.moveOrPassTarget",
228
+ "square.moveTarget",
229
+ "square.passTarget",
230
+ "square.piece",
231
+ "square.pieceWithBall",
232
+ "square.selected",
222
233
  "teamBlack",
223
234
  "teamWhite",
224
235
  "waitingRival",
@@ -228,6 +239,7 @@ var PIECE_I18N_KEYS = [...Object.values(chessFootballEngine.SHORT_KEY), ...Objec
228
239
  (label) => `pieces.${label}`
229
240
  );
230
241
  var EVENT_TOAST_I18N_KEYS = [
242
+ "eventToast.goal",
231
243
  "eventToast.interception",
232
244
  "eventToast.offside",
233
245
  "eventToast.tackle"
@@ -237,7 +249,13 @@ var GAME_I18N_KEYS = [
237
249
  ...PIECE_I18N_KEYS,
238
250
  ...EVENT_TOAST_I18N_KEYS
239
251
  ];
240
- var fallback = (key) => key;
252
+ var nodeEnv = globalThis.process?.env?.NODE_ENV;
253
+ var fallback = (key) => {
254
+ if (nodeEnv && nodeEnv !== "production") {
255
+ console.warn(`[chess-football-ui] missing game i18n key: "${key}" (no GameI18nProvider?)`);
256
+ }
257
+ return key;
258
+ };
241
259
  var GameI18nContext = React.createContext(fallback);
242
260
  function GameI18nProvider({ t, children }) {
243
261
  return /* @__PURE__ */ jsxRuntime.jsx(GameI18nContext.Provider, { value: t, children: /* @__PURE__ */ jsxRuntime.jsx(framerMotion.MotionConfig, { reducedMotion: "user", children }) });
@@ -444,6 +462,7 @@ var PIECE_SLIDE = { type: "spring", stiffness: 190, damping: 28, mass: 1 };
444
462
  var BALL_SLIDE = { type: "spring", stiffness: 300, damping: 26, mass: 0.6 };
445
463
  function GameBoard({ userSide, showCoordinates = false, keyboardNav = true, toolbar = true }) {
446
464
  const t = useGameT();
465
+ const boardId = React.useId();
447
466
  const prefersReducedMotion = framerMotion.useReducedMotion();
448
467
  const {
449
468
  boardState,
@@ -674,9 +693,28 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true, tool
674
693
  break;
675
694
  }
676
695
  };
677
- const renderSquares = () => {
678
- const squares = [];
696
+ const cellId = (x, y) => `${boardId}-sq-${x}-${y}`;
697
+ const squareLabel = (x, y, pieceAt, isValidMove, isValidPass) => {
698
+ const square = chessFootballEngine.squareName({ x, y });
699
+ const ballHere = ball.pos.x === x && ball.pos.y === y;
700
+ const parts = [];
701
+ if (pieceAt) {
702
+ const piece = t(`pieces.${chessFootballEngine.LONG_KEY[pieceAt.type]}`);
703
+ const team = pieceAt.side === "white" ? t("teamWhite") : t("teamBlack");
704
+ parts.push(t(ballHere ? "square.pieceWithBall" : "square.piece", { square, piece, team }));
705
+ } else {
706
+ parts.push(t(ballHere ? "square.emptyWithBall" : "square.empty", { square }));
707
+ }
708
+ if (isValidMove && isValidPass) parts.push(t("square.moveOrPassTarget"));
709
+ else if (isValidMove) parts.push(t("square.moveTarget"));
710
+ else if (isValidPass) parts.push(t("square.passTarget"));
711
+ if (pieceAt && pieceAt.id === selectedPieceId) parts.push(t("square.selected"));
712
+ return parts.join(", ");
713
+ };
714
+ const renderRows = () => {
715
+ const rows = [];
679
716
  for (let y = ROWS - 1; y >= 0; y--) {
717
+ const squares = [];
680
718
  for (let x = 0; x < COLS; x++) {
681
719
  const isValidMove = validMoves.some((m) => m.x === x && m.y === y);
682
720
  const isValidPass = validPasses.some((p) => p.x === x && p.y === y);
@@ -693,6 +731,10 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true, tool
693
731
  /* @__PURE__ */ jsxRuntime.jsxs(
694
732
  "div",
695
733
  {
734
+ id: cellId(x, y),
735
+ role: "gridcell",
736
+ "aria-label": squareLabel(x, y, pieceAt, isValidMove, isValidPass),
737
+ "aria-selected": !!pieceAt && pieceAt.id === selectedPieceId,
696
738
  onClick: () => handleSquareClick(x, y),
697
739
  onMouseEnter: () => {
698
740
  if (isValidPass) setHoveredPassAt({ x, y });
@@ -766,26 +808,23 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true, tool
766
808
  )
767
809
  );
768
810
  }
811
+ rows.push(
812
+ /* @__PURE__ */ jsxRuntime.jsx("div", { role: "row", className: "contents", children: squares }, `row-${y}`)
813
+ );
769
814
  }
770
- return squares;
815
+ return rows;
771
816
  };
772
817
  const board = /* @__PURE__ */ jsxRuntime.jsxs(
773
818
  "div",
774
819
  {
820
+ "data-testid": "board-root",
775
821
  className: cn(
776
822
  // §6: Mobile edge-to-edge — thin 2px margin, no border, no radius
777
823
  "w-full mx-0.5 bg-[#1a3317] overflow-hidden",
778
824
  // §5+§6: Desktop — centered, rounded, bordered, height-based max-width
779
825
  "md:mx-auto md:rounded-xl md:shadow-2xl md:border-4 md:border-[#1a3317]",
780
- "md:max-w-[min(700px,calc((100dvh_-_150px)*0.75))]",
781
- keyboardNav && "focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-green focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary"
826
+ "md:max-w-[min(700px,calc((100dvh_-_150px)*0.75))]"
782
827
  ),
783
- ...keyboardNav ? {
784
- tabIndex: 0,
785
- role: "application",
786
- "aria-label": "Game board \u2014 arrow keys to move the cursor, Enter to act",
787
- onKeyDown: handleKeyDown
788
- } : {},
789
828
  children: [
790
829
  toolbar && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "hidden md:flex items-center justify-between gap-2 px-2.5 py-1", "data-testid": "board-toolbar", children: [
791
830
  /* @__PURE__ */ jsxRuntime.jsx(BallHolderChip, {}),
@@ -794,7 +833,12 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true, tool
794
833
  {
795
834
  ref: shortcutsRef,
796
835
  className: "relative",
797
- onKeyDown: (e) => e.stopPropagation(),
836
+ onKeyDown: (e) => {
837
+ if (e.key === "Escape" && shortcutsOpen) {
838
+ e.stopPropagation();
839
+ setShortcutsOpen(false);
840
+ }
841
+ },
798
842
  children: [
799
843
  /* @__PURE__ */ jsxRuntime.jsx(
800
844
  "button",
@@ -826,7 +870,25 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true, tool
826
870
  )
827
871
  ] }),
828
872
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative", style: { containerType: "inline-size" }, children: [
829
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-9 gap-[1px]", children: renderSquares() }),
873
+ /* @__PURE__ */ jsxRuntime.jsx(
874
+ "div",
875
+ {
876
+ role: "grid",
877
+ "aria-label": t("boardAriaLabel"),
878
+ "aria-rowcount": ROWS,
879
+ "aria-colcount": COLS,
880
+ className: cn(
881
+ "grid grid-cols-9 gap-[1px]",
882
+ keyboardNav && "focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent-green"
883
+ ),
884
+ ...keyboardNav ? {
885
+ tabIndex: 0,
886
+ onKeyDown: handleKeyDown,
887
+ "aria-activedescendant": cursor ? cellId(cursor.x, cursor.y) : void 0
888
+ } : {},
889
+ children: renderRows()
890
+ }
891
+ ),
830
892
  /* @__PURE__ */ jsxRuntime.jsx(GrassOverlay, {}),
831
893
  /* @__PURE__ */ jsxRuntime.jsx(PitchMarkings, {}),
832
894
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -852,7 +914,7 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true, tool
852
914
  )
853
915
  }
854
916
  ),
855
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 pointer-events-none", "data-testid": "pieces-layer", children: [
917
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 pointer-events-none", "data-testid": "pieces-layer", "aria-hidden": "true", children: [
856
918
  /* @__PURE__ */ jsxRuntime.jsx(framerMotion.AnimatePresence, { mode: "sync", children: boardState.pieces.map((piece) => {
857
919
  const isPickupCarrier = pickupCarrierId === piece.id;
858
920
  const pieceTransition = isPickupCarrier ? { duration: PICKUP_DURATION, ease: "linear" } : PIECE_SLIDE;
@@ -1087,10 +1149,11 @@ function Scoreboard({
1087
1149
  align: "left"
1088
1150
  }
1089
1151
  ),
1090
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-3", children: [
1091
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-anton text-[32px] leading-none text-fg-primary tabular-nums", children: myScore }),
1092
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-anton text-2xl text-fg-muted", children: "\u2014" }),
1093
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-anton text-[32px] leading-none text-fg-primary tabular-nums", children: rivalScore })
1152
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { role: "status", "aria-live": "polite", "aria-atomic": "true", className: "flex items-center gap-3", children: [
1153
+ /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", className: "font-anton text-[32px] leading-none text-fg-primary tabular-nums", children: myScore }),
1154
+ /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", className: "font-anton text-2xl text-fg-muted", children: "\u2014" }),
1155
+ /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", className: "font-anton text-[32px] leading-none text-fg-primary tabular-nums", children: rivalScore }),
1156
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "sr-only", children: t("scoreAriaLabel", { me: myName, myScore, rival: rivalName, rivalScore }) })
1094
1157
  ] }),
1095
1158
  /* @__PURE__ */ jsxRuntime.jsx(
1096
1159
  PlayerInfo,
@@ -1158,15 +1221,21 @@ function MiniScoreboard({
1158
1221
  return /* @__PURE__ */ jsxRuntime.jsxs(
1159
1222
  "div",
1160
1223
  {
1224
+ role: "status",
1225
+ "aria-live": "polite",
1226
+ "aria-atomic": "true",
1161
1227
  className: cn(
1162
1228
  "w-full max-w-[540px] rounded-xl bg-bg-surface border border-border-subtle",
1163
1229
  "flex items-center justify-between gap-4 px-5 py-3",
1164
1230
  className
1165
1231
  ),
1166
1232
  children: [
1167
- /* @__PURE__ */ jsxRuntime.jsx(SideCell, { name: myName, team: myTeam, score: myScore, isActive: isMyTurn, align: "left" }),
1168
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-anton text-base text-fg-muted uppercase tracking-[2px]", "aria-hidden": "true", children: "vs" }),
1169
- /* @__PURE__ */ jsxRuntime.jsx(SideCell, { name: rivalName, team: rivalTeam, score: rivalScore, isActive: !isMyTurn, align: "right" })
1233
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "contents", "aria-hidden": "true", children: [
1234
+ /* @__PURE__ */ jsxRuntime.jsx(SideCell, { name: myName, team: myTeam, score: myScore, isActive: isMyTurn, align: "left" }),
1235
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-anton text-base text-fg-muted uppercase tracking-[2px]", children: "vs" }),
1236
+ /* @__PURE__ */ jsxRuntime.jsx(SideCell, { name: rivalName, team: rivalTeam, score: rivalScore, isActive: !isMyTurn, align: "right" })
1237
+ ] }),
1238
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "sr-only", children: t("scoreAriaLabel", { me: myName, myScore, rival: rivalName, rivalScore }) })
1170
1239
  ]
1171
1240
  }
1172
1241
  );
@@ -1188,7 +1257,12 @@ function SideCell({ name, team, score, isActive, align }) {
1188
1257
  ] });
1189
1258
  }
1190
1259
  var VARIANT = {
1191
- primary: "bg-accent-green text-fg-primary font-anton tracking-widest rounded-md hover:bg-accent-green-light active:bg-accent-green-dark",
1260
+ // White on `--accent-green` is 4.27:1 under WCAG AA for the Anton 400 the
1261
+ // primary CTA is set in. `--accent-green-dark` clears it (~6.5:1), so the
1262
+ // resting state is the dark green and interaction brightens it rather than
1263
+ // darkening it. Hover/active are transient; the resting state is the one a
1264
+ // player reads.
1265
+ primary: "bg-accent-green-dark text-fg-primary font-anton tracking-widest rounded-md hover:bg-accent-green active:bg-accent-green-light",
1192
1266
  secondary: "bg-bg-surface text-fg-primary font-inter font-semibold rounded-md border border-border-subtle hover:bg-bg-surface-elevated active:bg-bg-secondary",
1193
1267
  ghost: "bg-transparent text-fg-secondary font-inter font-medium rounded-md hover:text-fg-primary active:text-fg-muted",
1194
1268
  destructive: "bg-danger/10 text-danger font-inter font-semibold rounded-md border border-danger hover:bg-danger/20 active:bg-danger/30"
@@ -1218,24 +1292,97 @@ var Button = React__namespace.forwardRef(
1218
1292
  )
1219
1293
  );
1220
1294
  Button.displayName = "Button";
1295
+ var FOCUSABLE = [
1296
+ "a[href]",
1297
+ "button:not([disabled])",
1298
+ "input:not([disabled])",
1299
+ "select:not([disabled])",
1300
+ "textarea:not([disabled])",
1301
+ '[tabindex]:not([tabindex="-1"])'
1302
+ ].join(",");
1303
+ function focusableWithin(panel) {
1304
+ return Array.from(panel.querySelectorAll(FOCUSABLE)).filter(
1305
+ (el) => !el.closest("[hidden]") && !el.closest('[aria-hidden="true"]')
1306
+ );
1307
+ }
1308
+ function useDialogA11y({ open, onClose }) {
1309
+ const panelRef = React.useRef(null);
1310
+ const restoreToRef = React.useRef(null);
1311
+ const onCloseRef = React.useRef(onClose);
1312
+ React.useEffect(() => {
1313
+ onCloseRef.current = onClose;
1314
+ });
1315
+ React.useEffect(() => {
1316
+ if (!open) return;
1317
+ const panel = panelRef.current;
1318
+ if (!panel) return;
1319
+ restoreToRef.current = document.activeElement;
1320
+ const initial = focusableWithin(panel)[0];
1321
+ if (initial) {
1322
+ initial.focus();
1323
+ } else {
1324
+ panel.tabIndex = -1;
1325
+ panel.focus();
1326
+ }
1327
+ const onKeyDown = (e) => {
1328
+ if (e.key === "Escape") {
1329
+ e.stopPropagation();
1330
+ onCloseRef.current();
1331
+ return;
1332
+ }
1333
+ if (e.key !== "Tab") return;
1334
+ const items = focusableWithin(panel);
1335
+ if (items.length === 0) {
1336
+ e.preventDefault();
1337
+ return;
1338
+ }
1339
+ const first = items[0];
1340
+ const last = items[items.length - 1];
1341
+ const active = document.activeElement;
1342
+ if (e.shiftKey && (active === first || !panel.contains(active))) {
1343
+ e.preventDefault();
1344
+ last.focus();
1345
+ } else if (!e.shiftKey && (active === last || !panel.contains(active))) {
1346
+ e.preventDefault();
1347
+ first.focus();
1348
+ }
1349
+ };
1350
+ document.addEventListener("keydown", onKeyDown, true);
1351
+ return () => {
1352
+ document.removeEventListener("keydown", onKeyDown, true);
1353
+ restoreToRef.current?.focus?.();
1354
+ };
1355
+ }, [open]);
1356
+ return panelRef;
1357
+ }
1221
1358
  function ConfirmDialog({ open, title, description, confirmLabel, cancelLabel, onConfirm, onCancel }) {
1359
+ const baseId = React.useId();
1360
+ const titleId = `${baseId}-title`;
1361
+ const descId = `${baseId}-desc`;
1362
+ const panelRef = useDialogA11y({ open, onClose: onCancel });
1222
1363
  if (!open) return null;
1223
1364
  return /* @__PURE__ */ jsxRuntime.jsx(
1224
1365
  "div",
1225
1366
  {
1226
1367
  className: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm",
1227
- role: "dialog",
1228
- "aria-modal": "true",
1368
+ onClick: onCancel,
1229
1369
  children: /* @__PURE__ */ jsxRuntime.jsxs(
1230
1370
  "div",
1231
1371
  {
1372
+ ref: panelRef,
1373
+ role: "dialog",
1374
+ "aria-modal": "true",
1375
+ "aria-labelledby": titleId,
1376
+ "aria-describedby": description ? descId : void 0,
1377
+ onClick: (e) => e.stopPropagation(),
1232
1378
  className: cn(
1233
1379
  "bg-bg-surface border border-border-subtle rounded-xl p-6 mx-4 max-w-sm w-full",
1234
- "flex flex-col gap-4 shadow-2xl"
1380
+ "flex flex-col gap-4 shadow-2xl",
1381
+ "focus:outline-none"
1235
1382
  ),
1236
1383
  children: [
1237
- /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "font-anton text-lg text-fg-primary uppercase tracking-wide", children: title }),
1238
- description && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "font-inter text-sm text-fg-secondary", children: description }),
1384
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { id: titleId, className: "font-anton text-lg text-fg-primary uppercase tracking-wide", children: title }),
1385
+ description && /* @__PURE__ */ jsxRuntime.jsx("p", { id: descId, className: "font-inter text-sm text-fg-secondary", children: description }),
1239
1386
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2 pt-1", children: [
1240
1387
  /* @__PURE__ */ jsxRuntime.jsx(Button, { variant: "secondary", className: "flex-1", onClick: onCancel, children: cancelLabel }),
1241
1388
  /* @__PURE__ */ jsxRuntime.jsx(Button, { variant: "primary", className: "flex-1", onClick: onConfirm, children: confirmLabel })
@@ -1561,8 +1708,9 @@ function TurnBanner({ isMyTurn, waitingLabel, className }) {
1561
1708
  }
1562
1709
  );
1563
1710
  }
1564
- var TOASTABLE = /* @__PURE__ */ new Set(["interception", "offside", "tackle"]);
1711
+ var TOASTABLE = /* @__PURE__ */ new Set(["goal", "interception", "offside", "tackle"]);
1565
1712
  var TOAST_CONFIG = {
1713
+ goal: "bg-accent-green/20 border-accent-green/60 text-accent-green-light",
1566
1714
  interception: "bg-danger/20 border-danger/50 text-danger",
1567
1715
  offside: "bg-warning/20 border-warning/50 text-warning",
1568
1716
  tackle: "bg-warning/20 border-warning/50 text-warning"
@@ -1610,6 +1758,8 @@ function EventToast({ className }) {
1610
1758
  function MobileHistory({ className }) {
1611
1759
  const { boardState } = chunkLTFNSTAQ_cjs.useGameStore();
1612
1760
  const [open, setOpen] = React.useState(false);
1761
+ const titleId = `${React.useId()}-title`;
1762
+ const panelRef = useDialogA11y({ open, onClose: () => setOpen(false) });
1613
1763
  const lastMove = boardState?.moveHistory?.at(-1);
1614
1764
  if (!lastMove) return null;
1615
1765
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
@@ -1635,11 +1785,13 @@ function MobileHistory({ className }) {
1635
1785
  animate: { y: 0 },
1636
1786
  exit: { y: "100%" },
1637
1787
  transition: { type: "spring", stiffness: 300, damping: 32 },
1638
- className: "fixed bottom-0 left-0 right-0 z-50 bg-bg-secondary rounded-t-2xl border-t border-border-subtle shadow-xl",
1788
+ ref: panelRef,
1789
+ className: "fixed bottom-0 left-0 right-0 z-50 bg-bg-secondary rounded-t-2xl border-t border-border-subtle shadow-xl focus:outline-none",
1639
1790
  role: "dialog",
1640
1791
  "aria-modal": "true",
1792
+ "aria-labelledby": titleId,
1641
1793
  children: [
1642
- /* @__PURE__ */ jsxRuntime.jsx(SheetHeader, { onClose: () => setOpen(false) }),
1794
+ /* @__PURE__ */ jsxRuntime.jsx(SheetHeader, { titleId, onClose: () => setOpen(false) }),
1643
1795
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-4 pb-6 max-h-[50dvh] overflow-y-auto", children: /* @__PURE__ */ jsxRuntime.jsx(MoveHistory, { scrollable: false }) })
1644
1796
  ]
1645
1797
  },
@@ -1648,17 +1800,17 @@ function MobileHistory({ className }) {
1648
1800
  ] }) })
1649
1801
  ] });
1650
1802
  }
1651
- function SheetHeader({ onClose }) {
1803
+ function SheetHeader({ titleId, onClose }) {
1652
1804
  const t = useGameT();
1653
1805
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between px-5 pt-4 pb-3 border-b border-border-subtle", children: [
1654
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-mono text-[10px] tracking-[0.5px] uppercase text-fg-muted", children: t("history.title") }),
1806
+ /* @__PURE__ */ jsxRuntime.jsx("span", { id: titleId, className: "font-mono text-[10px] tracking-[0.5px] uppercase text-fg-muted", children: t("history.title") }),
1655
1807
  /* @__PURE__ */ jsxRuntime.jsx(
1656
1808
  "button",
1657
1809
  {
1658
1810
  onClick: onClose,
1659
1811
  className: "w-8 h-8 flex items-center justify-center rounded-full bg-bg-surface hover:bg-bg-surface-elevated text-fg-muted hover:text-fg-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-green",
1660
- "aria-label": "Close",
1661
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { size: 14 })
1812
+ "aria-label": t("history.close"),
1813
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { size: 14, "aria-hidden": "true" })
1662
1814
  }
1663
1815
  )
1664
1816
  ] });
@@ -1742,6 +1894,7 @@ exports.TurnBanner = TurnBanner;
1742
1894
  exports.cn = cn;
1743
1895
  exports.isGoalAreaSquare = isGoalAreaSquare;
1744
1896
  exports.pitchSquareClass = pitchSquareClass;
1897
+ exports.useDialogA11y = useDialogA11y;
1745
1898
  exports.useGameT = useGameT;
1746
1899
  //# sourceMappingURL=index.cjs.map
1747
1900
  //# sourceMappingURL=index.cjs.map