Compare commits

...

16 Commits

Author SHA1 Message Date
Michael Pilosov
98f423b794 feat: set White/Black names in the Save dialog too
Mirrors the Export PGN dialog: Save now has White/Black fields (pre-filled,
placeholders blank) and writes the names back to headers + on-screen info, so a
saved game stores real player names instead of You/Opponent. Shared prefill/
write-back helpers between the two dialogs. Bump cache version.
2026-06-25 22:02:38 +00:00
Michael Pilosov
3974c9faab feat: set White/Black player names when exporting a PGN
Export PGN now opens a dialog with White/Black fields (pre-filled from the game
headers, placeholders treated as blank). Entered names are written back, so they
appear in the exported PGN, update the on-screen 'X vs Y' line, and persist if the
game is saved — instead of always exporting 'You vs Opponent'. Bump cache version.
2026-06-25 21:45:03 +00:00
Michael Pilosov
791cc35962 polish: route PGN-error alerts through the in-app dialog too
Replaces the last native alert() calls (corrupted saved game / unparseable PGN)
and the delete confirm with the same <dialog>-based modalAlert/modalConfirm
helpers, so no flow relies on native popups that the browser may suppress.
Bump cache version.
2026-06-25 21:28:39 +00:00
Michael Pilosov
233ecb97c0 fix: save/delete use in-app dialogs instead of native prompt/confirm
The browser suppresses native confirm()/prompt() when the page isn't the active
tab ('dialog was suppressed because this page is not the active tab'). A
suppressed confirm() returns false, so the overwrite-on-save check silently
cancelled — no dialog, no overwrite. Replaced the prompt+confirm flow with a real
<dialog> save modal (name input + live 'this overwrites' warning, so Save is the
confirmation) and moved delete onto an in-app confirm dialog too. No native
popups in these flows now. Bump cache version.
2026-06-25 21:13:02 +00:00
Michael Pilosov
a2e62f4e5e code -> source code 2026-06-25 20:58:33 +00:00
Michael Pilosov
8acd9ef8d2 fix: saving a game with an existing name overwrites instead of duplicating
saveCurrentGame always pushed a new entry, so re-saving under the same title
produced two dropdown entries. Now it warns + confirms, then replaces the
same-named game (case-insensitive), collapsing any existing duplicates into one.
Bump asset cache version.
2026-06-25 20:54:54 +00:00
Michael Pilosov
cf24009e31 feat: opt-in PWA/offline behind ENABLE_CHESS_PWA flag; add cache-version bump
- sw.js + manifest.json: cache-first service worker for instant repeat-visit
  loads and full offline use. Registered only when window.CHESS_PWA is true.
- Disabled by default: an active SW serves returning visitors from cache, so
  they'd stop hitting the server and disappear from the Fly logs. Enable at
  build time with --build-arg ENABLE_CHESS_PWA=1 (Dockerfile flips the flag);
  ENABLE_CHESS_PWA=1 make dev does the same locally.
- scripts/bump_version.sh + 'make bump': bump the asset cache version across
  index.html (?v=N) and sw.js (CACHE) in sync. Required before deploy so the
  service worker / browser cache pick up changed assets. Bumped v25 -> v26.
- README: document cache versioning + PWA flag.
2026-06-25 20:47:19 +00:00
Michael Pilosov
85640e6f8d ui: drop fork tracking for new games; fix stale fork badge
New games have no reference line to return to, so rewinding to fix a finger-slip
no longer shows a 'forked' badge — corrections just overwrite silently. Loaded
games (built-in / uploaded / saved) keep 'Reset to original', but the badge now
reads 'Modified from original' (no move number), which fixes the stale-fork-point
bug: the indicator stayed pinned to the first divergence and ignored later
rewinds. Resettable correctly across any number of rewinds now.
2026-06-25 20:37:47 +00:00
Michael Pilosov
1d9f9459df ui: default to a new game; clean placeholder game info; status beside info
- Open on a fresh 'You vs Opponent' game instead of the Immortal Game.
- Hide chess.js Seven-Tag-Roster placeholders ("?", "????.??.??") so a new
  game reads just "You vs Opponent" rather than "You vs Opponent — ? — ????.??.??".
- Move the turn/move status onto the same row as the game info (top of the side
  panel) instead of below the move list.
2026-06-25 19:44:35 +00:00
Michael Pilosov
ba1d993676 feat: move engine fully client-side; serve as static; add save/export
Gameplay (legal moves, move application, pressure maps, PGN parsing) now runs
entirely in the browser via a chess.js port of engine.py (static/engine.js +
vendored chess.min.js). The app no longer calls the server during play, so it
stays instant and works even when the backend is scaled to zero or offline.

- engine.js: line-for-line port of engine.py; parity-verified identical output
  (pressure, FEN, status, SAN/UCI) across all 10 built-in games / 829 frames.
- app.js: replace all /api fetches with local engine calls; graceful PGN parse
  errors; asset paths moved to root for static serving.
- Save games to localStorage + load from the game dropdown; export any game as
  a .pgn file. Minimal additive UI in the existing export row.
- games.json generated from games.py (scripts/export_games.py).
- Production Dockerfile -> static-web-server (~9MB image, was ~150MB python).
- make dev -> bun static server; add make games / make parity targets.
- app.py kept as optional fallback (serves static app + /api routes).
- parity/: automated JS-vs-python engine parity test.

git diff stat:

 16 files changed, 722 insertions(+), 128 deletions(-)
2026-06-25 19:41:55 +00:00
Michael Pilosov
94e890f3ca fix: rewinding and replaying a move now overwrites the line instead of appending
doMove only truncated moves/frames on the first divergence (forkPoint===null).
Subsequent rewind+move appended to the end of the line, corrupting the game
state irrecoverably. Always truncate to the current point before pushing.
Drop the dead forkedMoves state.
2026-06-25 19:29:27 +00:00
Michael Pilosov
1d8d8cec14 fix playback buttons: flex-basis 0 for proportional sizing, full width 2026-06-25 19:15:29 +00:00
Michael Pilosov
4ab86fa6a0 refactor: playback buttons full-width with double-triangle ff/rw icons
- btn-prev/next now use |◄ / ►| (single triangle + bar, was btn-start/end)
- btn-start/end now use |◄◄ / ►►| (double triangle + bar)
- playback-buttons row fills full width with flex:1 on all buttons
- outer two buttons (start/end) slightly smaller via flex:0.65 + 13px SVG
2026-06-25 19:08:00 +00:00
Michael Pilosov
1c04782db5 ui: replace emoji playback icons with SVG, restyle pressure controls
- All 5 playback buttons now use inline SVG triangles/bars — no more
  emoji rendering on iOS/Android
- Pause icon in togglePlay() also switches to SVG
- Radio mode toggle becomes a segmented pill control (own row, full width)
- Piece/Board checkboxes each get their own tap-friendly row (min 2.5rem tall)
2026-06-25 19:00:50 +00:00
Michael Pilosov
b2432305b5 installation 2026-04-19 18:13:51 +00:00
Michael Pilosov
f6d1b3a56c Add source link footer pointing to GitHub repo 2026-04-07 12:25:26 -06:00
19 changed files with 1236 additions and 186 deletions

7
.gitignore vendored
View File

@ -48,3 +48,10 @@ htmlcov/
# mypy
.mypy_cache/
# Node / parity test
node_modules/
parity/.py_frames.json
parity/.dumperr
bun.lock
bun.lockb

View File

@ -1,15 +1,31 @@
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
# Fully client-side app — production is just static files served by a tiny,
# fast-booting static-web-server (Rust). No Python in the runtime image.
#
# PWA/offline service worker is OFF by default (so all visitors hit the server
# and show up in logs). Enable it at build time:
# docker build --build-arg ENABLE_CHESS_PWA=1 .
# fly deploy --build-arg ENABLE_CHESS_PWA=1
WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
COPY src/ src/
RUN uv sync --no-dev --locked --no-editable
ARG ENABLE_CHESS_PWA=0
FROM python:3.13-slim-bookworm
FROM alpine:3 AS build
ARG ENABLE_CHESS_PWA
WORKDIR /src
COPY src/chess_pressure/static/ ./static/
RUN if [ "$ENABLE_CHESS_PWA" = "1" ]; then \
sed -i 's/window.CHESS_PWA = false/window.CHESS_PWA = true/' static/index.html; \
echo "chess-pressure: PWA ENABLED"; \
else \
echo "chess-pressure: PWA disabled"; \
fi
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
FROM ghcr.io/static-web-server/static-web-server:2
COPY --from=build /src/static /public
ENV SERVER_HOST=0.0.0.0 \
SERVER_PORT=8888 \
SERVER_ROOT=/public \
SERVER_FALLBACK_PAGE=/public/index.html \
SERVER_COMPRESSION=true
EXPOSE 8888
CMD ["chess-pressure"]

View File

@ -1,13 +1,28 @@
.PHONY: dev serve lint fmt build publish deploy logs status help
.PHONY: dev serve games parity bump lint fmt build publish fly-install deploy logs status help
# Development server with auto-reload
# Static dev server (client-side app; refresh picks up edits)
dev:
uv run uvicorn chess_pressure.app:app --host 0.0.0.0 --port 8888 --reload
bun run dev-server.js
# Production server
# Optional Python fallback server (also serves the static app + /api routes)
serve:
uv run chess-pressure
# Regenerate static/games.json from games.py (run after editing games)
games:
uv run python scripts/export_games.py
# Verify the JS engine matches python-chess across every built-in game
parity:
cd parity && bun install --silent
uv run python parity/dump_python.py > parity/.py_frames.json
cd parity && bun run check.js
# Bump static asset cache-bust version (index.html + sw.js). Run before deploy
# when any cached asset changed — required for the PWA service worker to update.
bump:
./scripts/bump_version.sh
# Lint
lint: fmt
uvx ruff check src/chess_pressure/
@ -26,6 +41,10 @@ build:
publish: build
uv publish
# Install flyctl
fly-install:
curl -L https://fly.io/install.sh | sh
# Deploy to Fly.io
deploy:
fly deploy -a chess-pressure
@ -41,12 +60,16 @@ status:
help:
@echo "chess-pressure"
@echo ""
@echo " make dev dev server with reload (:8888)"
@echo " make serve production server (:8888)"
@echo " make dev static dev server (:8888)"
@echo " make serve optional Python fallback server (:8888)"
@echo " make games regenerate static/games.json from games.py"
@echo " make parity verify JS engine matches python-chess"
@echo " make bump bump asset cache version (run before deploy)"
@echo " make lint ruff format + check"
@echo " make fmt ruff format + auto-fix"
@echo " make build build sdist + wheel"
@echo " make publish build + publish to PyPI"
@echo " make deploy deploy to Fly.io"
@echo " make publish build + publish to PyPI"
@echo " make fly-install install flyctl"
@echo " make deploy deploy to Fly.io"
@echo " make logs tail production logs"
@echo " make status Fly.io app status"

View File

@ -14,7 +14,23 @@ For each square on the board, pressure is the net number of pieces attacking it.
- Step through famous built-in games move by move and watch the pressure heatmap evolve.
- Upload your own PGN to analyze any game.
- Make moves from any position and see legal moves + pressure updates in real time.
- FastAPI backend with a lightweight static frontend.
- Save games to your browser and re-load them later; export any game as a PGN file.
- **Fully client-side gameplay** — legal moves, move application, and pressure are all
computed in the browser (via [chess.js](https://github.com/jhlywa/chess.js)), so the
app keeps working instantly even when the server is asleep or offline. Production is
served as plain static files (no backend in the request path).
## Architecture
The chess logic exists in two equivalent implementations:
- `engine.py` (python-chess) — the original engine; also the parity reference.
- `static/engine.js` (chess.js) — a line-for-line port that runs in the browser.
`make parity` verifies the two produce **identical** output (pressure maps, FENs,
board status, and SAN/UCI) across every built-in game, so the client engine can be
trusted as a drop-in. Production deploys only the static assets; the FastAPI server
(`app.py`, with the `/api` routes below) is kept as an optional local fallback.
## Built-in Games
@ -34,12 +50,61 @@ For each square on the board, pressure is the net number of pieces attacking it.
## Quickstart
```bash
uv run chess-pressure
make dev # static dev server on http://localhost:8888 (bun)
```
The app starts on [http://localhost:8888](http://localhost:8888).
Other useful targets:
## API
```bash
make serve # optional Python fallback server (also serves the static app)
make games # regenerate static/games.json after editing games.py
make parity # verify the JS engine matches python-chess
make bump # bump the asset cache version (run before deploy — see below)
make deploy # deploy the static image to Fly.io
```
## Deployment
Production is a ~9 MB image built on
[static-web-server](https://static-web-server.net/) (see `Dockerfile`) — it serves
`static/` and nothing else, so it boots fast and scales to zero cleanly. The only
cold-start cost is the initial page load; all gameplay afterwards is local.
### Cache versioning (run before every deploy)
Static assets are cache-busted by a single version number that appears in two
places: `index.html` (`style.css?v=N`, `app.js?v=N`) and `sw.js`
(`CACHE = "chess-pressure-vN"`). **Whenever you change a cached asset, bump it:**
```bash
make bump # v25 -> v26 across index.html and sw.js, kept in sync
```
This matters for the normal browser cache, and is **critical when the PWA is
enabled** — the service worker will otherwise keep serving the old assets to
returning visitors forever. Forgetting `make bump` is the #1 "why am I seeing old
code?" cause. Commit the bump along with your asset change, then `make deploy`.
### Offline / PWA (opt-in, off by default)
The app can install a service worker (`sw.js` + `manifest.json`) that caches the
whole app shell, so **repeat visits load instantly and work fully offline**. It is
**disabled by default on purpose**: an active service worker serves returning
visitors entirely from cache, so they no longer hit the server and won't appear in
the Fly logs. Enable it at build time:
```bash
docker build --build-arg ENABLE_CHESS_PWA=1 .
fly deploy --build-arg ENABLE_CHESS_PWA=1 # to ship it on Fly
ENABLE_CHESS_PWA=1 make dev # to test it locally
```
The flag flips `window.CHESS_PWA` in `index.html`; when false, the service worker
is never registered. Remember to `make bump` on every deploy once it's on.
## API (optional Python server)
The static app does not use these, but `app.py` still exposes them:
| Method | Endpoint | Description |
|--------|----------|-------------|
@ -54,10 +119,20 @@ The app starts on [http://localhost:8888](http://localhost:8888).
```
src/chess_pressure/
__init__.py
app.py # FastAPI routes and static file serving
engine.py # Pressure computation, PGN parsing, move logic
games.py # Built-in famous games (PGN data)
static/ # Frontend assets (HTML, JS, CSS, piece images)
app.py # Optional FastAPI server (/api routes + static fallback)
engine.py # Pressure computation, PGN parsing, move logic (parity reference)
games.py # Built-in famous games (PGN data)
static/
engine.js # Browser port of engine.py (chess.js)
chess.min.js # Vendored chess.js 1.4.0 (bundled global)
games.json # Built-in games, generated from games.py
sw.js # Service worker (opt-in PWA/offline cache)
manifest.json # PWA manifest
app.js, ... # Frontend (HTML, JS, CSS, piece images)
scripts/export_games.py # games.py -> static/games.json
scripts/bump_version.sh # bump the asset cache version (make bump)
parity/ # JS-vs-python engine parity test
dev-server.js # bun static dev server (ENABLE_CHESS_PWA=1 to test PWA)
```
## License

37
dev-server.js Normal file
View File

@ -0,0 +1,37 @@
// Lightweight static dev server for chess-pressure.
// Serves src/chess_pressure/static/ at the root, matching the production
// static-web-server layout. Files are read fresh per request, so editing a
// JS/CSS/HTML file and refreshing the browser picks up changes immediately.
//
// bun run dev-server.js # serves http://0.0.0.0:8888
// PORT=9000 bun run dev-server.js
const ROOT = new URL("./src/chess_pressure/static/", import.meta.url).pathname;
const PORT = Number(process.env.PORT || 8888);
const PWA = process.env.ENABLE_CHESS_PWA === "1"; // mirror the Docker build flag
Bun.serve({
port: PORT,
hostname: "0.0.0.0",
async fetch(req) {
let pathname = decodeURIComponent(new URL(req.url).pathname);
if (pathname === "/" || pathname === "") pathname = "/index.html";
// Strip leading slashes and neutralize any ".." path traversal.
const rel = pathname.replace(/^\/+/, "").replace(/\.\.(\/|\\|$)/g, "");
const file = Bun.file(ROOT + rel);
if (!(await file.exists())) return new Response("Not found", { status: 404 });
const headers = { "Cache-Control": "no-cache" };
// Flip the PWA flag in index.html on the fly so `ENABLE_CHESS_PWA=1 make dev`
// can exercise the service worker without editing source.
if (PWA && rel === "index.html") {
const html = (await file.text()).replace("window.CHESS_PWA = false", "window.CHESS_PWA = true");
return new Response(html, { headers: { ...headers, "Content-Type": "text/html;charset=utf-8" } });
}
return new Response(file, { headers });
},
});
console.log(
`chess-pressure dev server → http://0.0.0.0:${PORT} (serving ${ROOT}) PWA=${PWA ? "on" : "off"}`,
);

93
parity/check.js Normal file
View File

@ -0,0 +1,93 @@
/* Parity test: assert the JS engine matches python-chess across every built-in game.
*
* Reads python output from .py_frames.json (produced by dump_python.py) and
* compares it to engine.js's parse of the same PGNs, frame by frame.
* Exits non-zero on any mismatch.
*/
const fs = require("fs");
const path = require("path");
const { Chess } = require("chess.js");
const makeEngine = require("../src/chess_pressure/static/engine.js");
const engine = makeEngine(Chess);
const pyData = JSON.parse(
fs.readFileSync(path.join(__dirname, ".py_frames.json"), "utf8"),
);
function eq(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
let failures = 0;
let framesChecked = 0;
for (const game of pyData) {
const js = engine.parsePgn(game.pgn);
if (js.result !== game.result) {
console.error(`[${game.id}] result mismatch: py=${game.result} js=${js.result}`);
failures++;
}
if (js.frames.length !== game.frames.length) {
console.error(
`[${game.id}] frame count mismatch: py=${game.frames.length} js=${js.frames.length}`,
);
failures++;
continue;
}
if (js.moves.length !== game.moves.length) {
console.error(
`[${game.id}] move count mismatch: py=${game.moves.length} js=${js.moves.length}`,
);
failures++;
}
for (let i = 0; i < game.frames.length; i++) {
const pf = game.frames[i];
const jf = js.frames[i];
framesChecked++;
if (!eq(pf.pressure, jf.pressure)) {
console.error(`[${game.id}] frame ${i}: unweighted pressure mismatch`);
failures++;
}
if (!eq(pf.pressure_weighted, jf.pressure_weighted)) {
console.error(`[${game.id}] frame ${i}: weighted pressure mismatch`);
failures++;
}
if (pf.board.fen !== jf.board.fen) {
console.error(
`[${game.id}] frame ${i}: fen mismatch\n py=${pf.board.fen}\n js=${jf.board.fen}`,
);
failures++;
}
if (
pf.board.is_check !== jf.board.is_check ||
pf.board.is_checkmate !== jf.board.is_checkmate ||
pf.board.is_stalemate !== jf.board.is_stalemate ||
pf.board.fullmove !== jf.board.fullmove ||
pf.board.turn !== jf.board.turn
) {
console.error(`[${game.id}] frame ${i}: board status mismatch`);
failures++;
}
}
for (let i = 0; i < Math.min(game.moves.length, js.moves.length); i++) {
if (game.moves[i].san !== js.moves[i].san || game.moves[i].uci !== js.moves[i].uci) {
console.error(
`[${game.id}] move ${i} mismatch: py=${game.moves[i].san}/${game.moves[i].uci} js=${js.moves[i].san}/${js.moves[i].uci}`,
);
failures++;
}
}
}
if (failures === 0) {
console.log(
`PARITY OK — ${pyData.length} games, ${framesChecked} frames identical (pressure, fen, status, moves).`,
);
process.exit(0);
} else {
console.error(`PARITY FAILED — ${failures} mismatch(es).`);
process.exit(1);
}

26
parity/dump_python.py Normal file
View File

@ -0,0 +1,26 @@
"""Dump python-chess engine output for every built-in game as JSON on stdout.
Consumed by check.js to verify the JS engine produces identical results.
Run from the package root: `uv run python parity/dump_python.py`.
"""
import json
import sys
from chess_pressure.engine import parse_pgn
from chess_pressure.games import GAMES
out = []
for g in GAMES:
parsed = parse_pgn(g["pgn"])
out.append(
{
"id": g["id"],
"pgn": g["pgn"],
"moves": parsed["moves"],
"frames": parsed["frames"],
"result": parsed["result"],
}
)
json.dump(out, sys.stdout)

8
parity/package.json Normal file
View File

@ -0,0 +1,8 @@
{
"name": "chess-pressure-parity",
"private": true,
"description": "Parity test: JS engine vs python-chess engine",
"dependencies": {
"chess.js": "1.4.0"
}
}

31
scripts/bump_version.sh Executable file
View File

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Bump the static-asset cache-bust version in one shot.
#
# The version appears in two places that must stay in sync:
# - index.html -> style.css?v=N and app.js?v=N (browser HTTP cache)
# - sw.js -> CACHE = "chess-pressure-vN" (service worker cache, PWA)
#
# Run this whenever you change app.js / style.css (or any cached asset) so
# returning visitors pick up the new files instead of a stale cached copy.
# This matters always for the browser cache, and is *critical* when the PWA
# service worker is enabled (ENABLE_CHESS_PWA=1) — without a bump the SW keeps
# serving the old assets forever.
set -euo pipefail
cd "$(dirname "$0")/.."
STATIC="src/chess_pressure/static"
INDEX="$STATIC/index.html"
SW="$STATIC/sw.js"
cur=$(grep -oE '\?v=[0-9]+' "$INDEX" | head -1 | grep -oE '[0-9]+' || true)
if [ -z "${cur:-}" ]; then
echo "error: could not find a '?v=N' version in $INDEX" >&2
exit 1
fi
next=$((cur + 1))
sed -i -E "s/\?v=$cur\b/?v=$next/g" "$INDEX"
sed -i -E "s/chess-pressure-v$cur\b/chess-pressure-v$next/g" "$SW"
echo "cache version bumped: v$cur -> v$next"
grep -nE '\?v=[0-9]+|chess-pressure-v[0-9]+' "$INDEX" "$SW" | sed 's/^/ /'

28
scripts/export_games.py Normal file
View File

@ -0,0 +1,28 @@
"""Export the built-in games to static/games.json for the client-side app.
The browser parses these PGNs locally (via chess.js) the server is no longer
in the gameplay path. Re-run after editing games.py:
uv run python scripts/export_games.py
"""
import json
from pathlib import Path
from chess_pressure.games import GAMES
OUT = Path(__file__).resolve().parent.parent / "src" / "chess_pressure" / "static" / "games.json"
data = [
{
"id": g["id"],
"name": g["name"],
"white": g["white"],
"black": g["black"],
"pgn": g["pgn"],
}
for g in GAMES
]
OUT.write_text(json.dumps(data, indent=2) + "\n")
print(f"Wrote {len(data)} games to {OUT}")

View File

@ -7,7 +7,6 @@ from pathlib import Path
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
@ -84,16 +83,12 @@ def do_move(body: MoveRequest):
# --- Static files ---
# The app is fully client-side; production is served statically (see Dockerfile).
# This Python server is kept as an optional fallback / parity reference and the
# /api routes above remain available, but the UI no longer depends on them.
# Mounted last so /api and /health take precedence.
app.mount("/static", StaticFiles(directory=str(STATIC)), name="static")
@app.get("/")
def index():
return FileResponse(
str(STATIC / "index.html"),
headers={"Cache-Control": "no-cache, must-revalidate"},
)
app.mount("/", StaticFiles(directory=str(STATIC), html=True), name="static")
def main():

View File

@ -10,15 +10,15 @@
let playTimer = null;
let pressureMode = "equal"; // "equal" or "weighted"
let board = null;
let forkPoint = null; // index where fork happened, null if on original line
let originalData = null; // saved original game before fork
let forkedMoves = []; // UCI moves made after fork point
let hasReference = false; // true for loaded games (built-in/upload/saved) that can be reset
let originalData = null; // pristine copy of a reference game, captured on first edit
let showPieces = true;
let showBoard = false;
let selectedSquare = null; // tap-to-move: currently selected source square
let legalMoves = []; // legal moves from selected square
const PLAY_SPEED = 800; // ms between auto-advance
const NEW_GAME_PGN = '[White "You"]\n[Black "Opponent"]\n[Result "*"]\n\n*';
// --- Pressure colors ---
function pressureColor(value, maxAbs) {
@ -117,59 +117,47 @@
return;
}
// Fetch legal moves for this square
fetch("/api/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fen, uci: square + square }), // dummy — we just need to check if piece exists
}).catch(() => {});
// Check if there's a piece here by seeing if any legal move starts from this square
// We need to ask the server for legal moves from this position
fetch(`/api/legal?fen=${encodeURIComponent(fen)}`).then(r => r.ok ? r.json() : null).then(data => {
if (!data) return;
const fromMoves = data.filter((m) => m.startsWith(square));
if (fromMoves.length === 0) {
clearSelection();
return;
}
// Legal moves originating from this square (computed locally)
const fromMoves = ChessPressure.legalMoves(fen).filter((m) => m.startsWith(square));
if (fromMoves.length === 0) {
clearSelection();
selectedSquare = square;
legalMoves = fromMoves;
return;
}
clearSelection();
selectedSquare = square;
legalMoves = fromMoves;
// Highlight
const srcEl = document.querySelector(`[data-square="${square}"]`);
if (srcEl) srcEl.classList.add("square-selected");
fromMoves.forEach((m) => {
const target = m.substring(2, 4);
const el = document.querySelector(`[data-square="${target}"]`);
if (el) el.classList.add("square-target");
});
// Highlight
const srcEl = document.querySelector(`[data-square="${square}"]`);
if (srcEl) srcEl.classList.add("square-selected");
fromMoves.forEach((m) => {
const target = m.substring(2, 4);
const el = document.querySelector(`[data-square="${target}"]`);
if (el) el.classList.add("square-target");
});
}
function doMove(fen, uci) {
fetch("/api/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fen, uci }),
})
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (!data) return;
if (forkPoint === null && currentIndex < gameData.frames.length - 1) {
originalData = JSON.parse(JSON.stringify(gameData));
forkPoint = currentIndex;
gameData.moves = gameData.moves.slice(0, currentIndex);
gameData.frames = gameData.frames.slice(0, currentIndex + 1);
forkedMoves = [];
}
gameData.moves.push({ san: data.san, uci: data.uci, ply: currentIndex + 1 });
gameData.frames.push(data.frame);
if (forkPoint !== null) forkedMoves.push(data.uci);
goTo(gameData.frames.length - 1);
updateForkUI();
});
let data;
try {
data = ChessPressure.makeMove(fen, uci); // computed locally — no server round-trip
} catch (e) {
return; // illegal move — ignore
}
// For reference games (built-in/upload/saved), snapshot the pristine line the
// first time the user diverges from it, so "Reset to original" works. New games
// have no reference — corrections just overwrite, with no fork UI.
if (hasReference && originalData === null && currentIndex < gameData.frames.length - 1) {
originalData = JSON.parse(JSON.stringify(gameData));
}
// Always overwrite any continuation past the current point, so rewinding and
// playing a different move replaces the old line instead of appending to it.
gameData.moves = gameData.moves.slice(0, currentIndex);
gameData.frames = gameData.frames.slice(0, currentIndex + 1);
gameData.moves.push({ san: data.san, uci: data.uci, ply: currentIndex + 1 });
gameData.frames.push(data.frame);
goTo(gameData.frames.length - 1);
updateForkUI();
}
// --- Move list ---
@ -186,13 +174,11 @@
const b = gameData.moves[i + 1];
const wClass = currentIndex === i + 1 ? "active" : "";
const bClass = b && currentIndex === i + 2 ? "active" : "";
const wForked = forkPoint !== null && i >= forkPoint ? " forked" : "";
const bForked = forkPoint !== null && i + 1 >= forkPoint ? " forked" : "";
html += `<div class="move-row">`;
html += `<span class="move-num">${num}.</span>`;
html += `<span class="move${wClass ? " " + wClass : ""}${wForked}" data-idx="${i + 1}">${w.san}</span>`;
html += `<span class="move${wClass ? " " + wClass : ""}" data-idx="${i + 1}">${w.san}</span>`;
if (b) {
html += `<span class="move${bClass ? " " + bClass : ""}${bForked}" data-idx="${i + 2}">${b.san}</span>`;
html += `<span class="move${bClass ? " " + bClass : ""}" data-idx="${i + 2}">${b.san}</span>`;
}
html += `</div>`;
}
@ -255,17 +241,23 @@
return;
}
const h = gameData.headers;
// chess.js fills the Seven Tag Roster with "?" / "????.??.??" placeholders;
// skip those so a fresh game just reads "You vs Opponent".
const real = (v) => v && v !== "?" && v !== "????.??.??";
const parts = [];
if (h.White) parts.push(`${h.White} vs ${h.Black || "?"}`);
if (h.Event) parts.push(h.Event);
if (h.Date) parts.push(h.Date);
if (real(h.White) || real(h.Black)) parts.push(`${h.White || "?"} vs ${h.Black || "?"}`);
if (real(h.Event)) parts.push(h.Event);
if (real(h.Date)) parts.push(h.Date);
el.textContent = parts.join(" — ");
}
// --- Playback ---
const ICON_PLAY = '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><polygon points="3,2 13,8 3,14"/></svg>';
const ICON_PAUSE = '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="2" width="4" height="12"/><rect x="9" y="2" width="4" height="12"/></svg>';
function togglePlay() {
playing = !playing;
document.getElementById("btn-play").innerHTML = playing ? "&#9646;&#9646;" : "&#9654;";
document.getElementById("btn-play").innerHTML = playing ? ICON_PAUSE : ICON_PLAY;
if (playing) {
playTimer = setInterval(() => {
if (currentIndex >= gameData.frames.length - 1) {
@ -301,67 +293,301 @@
}
function updateForkUI() {
const el = document.getElementById("fork-controls");
if (forkPoint !== null) {
el.style.display = "flex";
document.getElementById("fork-point").textContent = Math.floor(forkPoint / 2) + 1;
} else {
el.style.display = "none";
}
document.getElementById("fork-controls").style.display = originalData ? "flex" : "none";
}
function resetFork() {
if (!originalData) return;
gameData = originalData;
originalData = null;
forkPoint = null;
forkedMoves = [];
originalData = null; // back on the pristine reference; still resettable after future edits
goTo(0);
updateForkUI();
}
// --- Game sources (built-in + browser-saved) ---
let BUILTIN_GAMES = [];
const SAVED_KEY = "cp.games";
let currentSavedId = null;
function getSavedGames() {
try {
return JSON.parse(localStorage.getItem(SAVED_KEY)) || [];
} catch (e) {
return [];
}
}
function setSavedGames(list) {
localStorage.setItem(SAVED_KEY, JSON.stringify(list));
}
function applyParsed(parsed, savedId, isReference) {
gameData = parsed;
hasReference = !!isReference;
originalData = null;
currentSavedId = savedId || null;
currentIndex = 0;
goTo(0);
updateGameInfo();
updateForkUI();
updateSavedControls();
}
// --- Load game ---
async function loadGame(gameId) {
const r = await fetch(`/api/games/${gameId}`);
if (!r.ok) return;
gameData = await r.json();
forkPoint = null;
originalData = null;
forkedMoves = [];
currentIndex = 0;
goTo(0);
updateGameInfo();
updateForkUI();
function loadGame(gameId) {
const g = BUILTIN_GAMES.find((x) => x.id === gameId);
if (!g) return;
applyParsed(ChessPressure.parsePgn(g.pgn), null, true);
}
async function loadPGN(pgn) {
const r = await fetch("/api/parse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pgn }),
});
if (!r.ok) {
alert("Failed to parse PGN");
function loadSaved(savedId) {
const g = getSavedGames().find((x) => x.id === savedId);
if (!g) return;
let parsed;
try {
parsed = ChessPressure.parsePgn(g.pgn);
} catch (e) {
modalAlert("That saved game could not be loaded (corrupted PGN).", "Couldn't load game");
return;
}
gameData = await r.json();
forkPoint = null;
originalData = null;
forkedMoves = [];
currentIndex = 0;
goTo(0);
updateGameInfo();
updateForkUI();
applyParsed(parsed, savedId, true);
}
// isReference defaults true (uploaded games are resettable); new games pass false.
function loadPGN(pgn, isReference) {
if (isReference === undefined) isReference = true;
let parsed;
try {
parsed = ChessPressure.parsePgn(pgn);
} catch (e) {
modalAlert("Could not parse PGN — please check the format and try again.", "Couldn't parse PGN");
return;
}
applyParsed(parsed, null, isReference);
document.getElementById("game-select").value = "";
}
// --- Saved games (localStorage) + PGN export ---
function populateGameSelect() {
const select = document.getElementById("game-select");
const prev = select.value;
select.innerHTML = '<option value="__new">New game</option>';
if (BUILTIN_GAMES.length) {
const og = document.createElement("optgroup");
og.label = "Famous games";
BUILTIN_GAMES.forEach((g) => {
const opt = document.createElement("option");
opt.value = g.id;
opt.textContent = g.name;
og.appendChild(opt);
});
select.appendChild(og);
}
const saved = getSavedGames();
if (saved.length) {
const og = document.createElement("optgroup");
og.label = "Saved games";
saved.forEach((g) => {
const opt = document.createElement("option");
opt.value = "saved:" + g.id;
opt.textContent = g.name;
og.appendChild(opt);
});
select.appendChild(og);
}
// Restore selection if it still exists
if (prev && [...select.options].some((o) => o.value === prev)) {
select.value = prev;
}
}
function updateSavedControls() {
const del = document.getElementById("btn-delete-saved");
if (del) del.style.display = currentSavedId ? "" : "none";
const select = document.getElementById("game-select");
if (currentSavedId) select.value = "saved:" + currentSavedId;
}
function defaultGameName() {
const h = (gameData && gameData.headers) || {};
if (h.White && h.Black) return `${h.White} vs ${h.Black}`;
return "My game";
}
function currentSavedName() {
if (!currentSavedId) return null;
const g = getSavedGames().find((x) => x.id === currentSavedId);
return g ? g.name : null;
}
// Player-name fields shared by the Save and Export dialogs. Pre-fill treats
// PGN placeholders ("?") as blank; write-back updates headers + on-screen info.
const cleanHeader = (v) => (v && v !== "?" && v !== "????.??.??" ? v : "");
function prefillPlayerInputs(whiteEl, blackEl) {
whiteEl.value = cleanHeader(gameData.headers.White);
blackEl.value = cleanHeader(gameData.headers.Black);
}
function applyPlayerInputs(whiteEl, blackEl) {
gameData.headers.White = whiteEl.value.trim() || "White";
gameData.headers.Black = blackEl.value.trim() || "Black";
updateGameInfo();
}
// Promise-based in-app modal. Native prompt()/confirm() are unreliable here —
// browsers suppress them when the page isn't the active tab — so all save/delete
// prompts go through a real <dialog>. Resolves to true (OK) or false (cancel/Esc).
function openModal(dlg) {
return new Promise((resolve) => {
const okBtn = dlg.querySelector("[data-ok]");
const cancelBtn = dlg.querySelector("[data-cancel]");
let done = false;
const finish = (val) => {
if (done) return;
done = true;
okBtn.removeEventListener("click", onOk);
cancelBtn.removeEventListener("click", onCancel);
dlg.removeEventListener("close", onClose);
dlg.removeEventListener("keydown", onKey);
if (dlg.open) dlg.close();
resolve(val);
};
const onOk = () => finish(true);
const onCancel = () => finish(false);
const onClose = () => finish(false);
const onKey = (e) => {
if (e.key === "Enter" && e.target !== cancelBtn) {
e.preventDefault();
finish(true);
}
};
okBtn.addEventListener("click", onOk);
cancelBtn.addEventListener("click", onCancel);
dlg.addEventListener("close", onClose);
dlg.addEventListener("keydown", onKey);
dlg.showModal();
});
}
// Yes/no confirmation via the in-app dialog. Resolves true (OK) / false (cancel).
function modalConfirm(message, title) {
const dlg = document.getElementById("confirm-dialog");
document.getElementById("confirm-title").textContent = title || "Please confirm";
document.getElementById("confirm-msg").textContent = message;
dlg.querySelector("[data-cancel]").style.display = "";
return openModal(dlg);
}
// Single-button notice via the in-app dialog (replaces native alert()).
function modalAlert(message, title) {
const dlg = document.getElementById("confirm-dialog");
document.getElementById("confirm-title").textContent = title || "Notice";
document.getElementById("confirm-msg").textContent = message;
const cancel = dlg.querySelector("[data-cancel]");
cancel.style.display = "none";
return openModal(dlg).then((v) => {
cancel.style.display = "";
return v;
});
}
async function saveCurrentGame() {
if (!gameData) return;
const dlg = document.getElementById("save-dialog");
const input = document.getElementById("save-name");
const note = document.getElementById("save-note");
const whiteEl = document.getElementById("save-white");
const blackEl = document.getElementById("save-black");
input.value = currentSavedName() || defaultGameName();
prefillPlayerInputs(whiteEl, blackEl);
// Live "this overwrites" warning, so clicking Save IS the confirmation.
const refreshNote = () => {
const key = input.value.trim().toLowerCase();
const exists = key && getSavedGames().some((g) => g.name.toLowerCase() === key);
note.style.display = exists ? "block" : "none";
};
input.addEventListener("input", refreshNote);
refreshNote();
setTimeout(() => { input.focus(); input.select(); }, 0);
const ok = await openModal(dlg);
input.removeEventListener("input", refreshNote);
if (!ok) return;
const name = input.value.trim();
if (!name) return;
applyPlayerInputs(whiteEl, blackEl);
// Replace any same-named game (case-insensitive); also collapses old duplicates.
const key = name.toLowerCase();
const saved = getSavedGames().filter((g) => g.name.toLowerCase() !== key);
const id = "g" + Date.now().toString(36);
saved.push({
id,
name,
pgn: ChessPressure.toPgn(gameData.headers, gameData.moves),
savedAt: new Date().toISOString(),
});
setSavedGames(saved);
currentSavedId = id;
populateGameSelect();
updateSavedControls();
}
async function deleteSavedGame() {
if (!currentSavedId) return;
const g = getSavedGames().find((x) => x.id === currentSavedId);
if (!g) return;
const ok = await modalConfirm(`Delete saved game "${g.name}"?`, "Delete game");
if (!ok) return;
setSavedGames(getSavedGames().filter((x) => x.id !== currentSavedId));
currentSavedId = null;
populateGameSelect();
updateSavedControls();
}
async function exportPGN() {
if (!gameData) return;
// Let the user set player names before download (shared with the Save dialog).
const whiteEl = document.getElementById("pgn-white");
const blackEl = document.getElementById("pgn-black");
prefillPlayerInputs(whiteEl, blackEl);
setTimeout(() => { whiteEl.focus(); whiteEl.select(); }, 0);
const ok = await openModal(document.getElementById("export-pgn-dialog"));
if (!ok) return;
applyPlayerInputs(whiteEl, blackEl);
const pgn = ChessPressure.toPgn(gameData.headers, gameData.moves);
const slug = (defaultGameName() || "game")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || "game";
const blob = new Blob([pgn], { type: "application/x-chess-pgn" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = slug + ".pgn";
a.click();
URL.revokeObjectURL(url);
}
// --- Init ---
async function init() {
// Build board
board = Chessboard("board", {
draggable: true,
position: "start",
pieceTheme: "/static/img/chesspieces/wikipedia/{piece}.png",
pieceTheme: "/img/chesspieces/wikipedia/{piece}.png",
onDrop: onDrop,
});
@ -376,32 +602,28 @@
const saved = localStorage.getItem("theme");
if (saved) document.documentElement.dataset.theme = saved;
// Load game list
const r = await fetch("/api/games");
const games = await r.json();
// Load built-in game list (static JSON, parsed in the browser)
const r = await fetch("/games.json");
BUILTIN_GAMES = await r.json();
const select = document.getElementById("game-select");
games.forEach((g) => {
const opt = document.createElement("option");
opt.value = g.id;
opt.textContent = g.name;
select.appendChild(opt);
});
populateGameSelect();
// Load first game by default
if (games.length) {
await loadGame(games[0].id);
select.value = games[0].id;
}
// Start on a fresh game so the player can begin immediately.
loadPGN(NEW_GAME_PGN, false);
select.value = "__new";
// Show board after first render (prevents tan flash)
document.getElementById("board").classList.add("ready");
// --- Event listeners ---
select.addEventListener("change", (e) => {
if (e.target.value === "__new") {
loadPGN('[White "You"]\n[Black "Opponent"]\n[Result "*"]\n\n*');
} else if (e.target.value) {
loadGame(e.target.value);
const v = e.target.value;
if (v === "__new") {
loadPGN(NEW_GAME_PGN, false);
} else if (v.startsWith("saved:")) {
loadSaved(v.slice(6));
} else if (v) {
loadGame(v);
}
});
@ -475,6 +697,11 @@
document.getElementById("export-cancel").addEventListener("click", () => exportDialog.close());
document.getElementById("export-go").addEventListener("click", exportGif);
// Save / export PGN / delete saved game
document.getElementById("btn-save").addEventListener("click", saveCurrentGame);
document.getElementById("btn-export-pgn").addEventListener("click", exportPGN);
document.getElementById("btn-delete-saved").addEventListener("click", deleteSavedGame);
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "TEXTAREA" || e.target.tagName === "INPUT") return;
@ -520,7 +747,7 @@
const img = new Image();
img.onload = () => { pieceImages[p] = img; resolve(); };
img.onerror = () => resolve();
img.src = `/static/img/chesspieces/wikipedia/${p}.png`;
img.src = `/img/chesspieces/wikipedia/${p}.png`;
}));
return Promise.all(promises);
}
@ -612,7 +839,7 @@
quality: 10,
width: EXPORT_SIZE,
height: EXPORT_SIZE,
workerScript: "/static/gif.worker.js",
workerScript: "/gif.worker.js",
});
const canvas = document.createElement("canvas");

15
src/chess_pressure/static/chess.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,154 @@
/* Chess Pressure client-side engine.
*
* A direct port of engine.py. Computes the same pressure maps and frame
* structures entirely in the browser, so gameplay never needs the server.
*
* UMD: in the browser it self-initializes from the global `Chess`
* (chess.min.js must load first) and exposes `window.ChessPressure`.
* Under Node/Bun it exports the factory so the parity test can inject its
* own Chess: `require("./engine.js")(require("chess.js").Chess)`.
*/
(function (root, factory) {
if (typeof module === "object" && module.exports) {
module.exports = factory; // caller supplies Chess
} else {
root.ChessPressure = factory(root.Chess);
}
})(typeof self !== "undefined" ? self : this, function (Chess) {
"use strict";
const PIECE_VALUES = { p: 1, n: 3, b: 3, r: 5, q: 9, k: 1 };
const FILES = "abcdefgh";
// square index 0..63 (a1=0, b1=1, ... h8=63) -> "e4" — matches python-chess.
const SQ_NAMES = [];
for (let i = 0; i < 64; i++) SQ_NAMES.push(FILES[i % 8] + (Math.floor(i / 8) + 1));
/** Net pressure per square. Positive = white controls, negative = black. */
function computePressure(chess, weighted) {
const pressure = new Array(64).fill(0);
for (let i = 0; i < 64; i++) {
const sq = SQ_NAMES[i];
for (const color of ["w", "b"]) {
const attackers = chess.attackers(sq, color);
const sign = color === "w" ? 1 : -1;
for (const from of attackers) {
const weight = weighted ? PIECE_VALUES[chess.get(from).type] : 1;
pressure[i] += sign * weight;
}
}
}
return pressure;
}
/** Serialize board state for the frontend (mirrors board_to_dict). */
function boardDict(chess) {
return {
fen: chess.fen(),
turn: chess.turn(),
is_check: chess.isCheck(),
is_checkmate: chess.isCheckmate(),
is_stalemate: chess.isStalemate(),
is_game_over: chess.isGameOver(),
fullmove: chess.moveNumber(),
};
}
function frameFor(chess) {
return {
board: boardDict(chess),
pressure: computePressure(chess, false),
pressure_weighted: computePressure(chess, true),
};
}
function uciOf(move) {
return move.from + move.to + (move.promotion || "");
}
/** Legal moves from a FEN as UCI strings (mirrors /api/legal). */
function legalMoves(fen) {
const chess = new Chess(fen);
return chess.moves({ verbose: true }).map(uciOf);
}
/** Apply a UCI move to a FEN (mirrors make_move). Throws on illegal. */
function makeMove(fen, uci) {
const chess = new Chess(fen);
const move = {
from: uci.slice(0, 2),
to: uci.slice(2, 4),
promotion: uci.length > 4 ? uci[4] : undefined,
};
let result;
try {
result = chess.move(move);
} catch (e) {
throw new Error("Illegal move: " + uci);
}
if (!result) throw new Error("Illegal move: " + uci);
return {
san: result.san,
uci,
frame: frameFor(chess),
legal_moves: chess.moves({ verbose: true }).map(uciOf),
};
}
/** Parse a PGN string into {headers, moves, frames, result} (mirrors parse_pgn). */
function parsePgn(pgnText) {
const parser = new Chess();
parser.loadPgn(pgnText); // throws on unparseable PGN
const headers = parser.getHeaders();
const history = parser.history({ verbose: true });
const replay = headers.FEN ? new Chess(headers.FEN) : new Chess();
const moves = [];
const frames = [frameFor(replay)]; // frame 0 = starting position
for (const mv of history) {
const san = mv.san;
const uci = uciOf(mv);
replay.move({ from: mv.from, to: mv.to, promotion: mv.promotion });
moves.push({ san, uci, ply: replay.history().length });
frames.push(frameFor(replay));
}
return {
headers,
moves,
frames,
result: headers.Result || "*",
};
}
/** Build a PGN-compatible string from headers + a list of {uci} moves. */
function toPgn(headers, moves) {
const chess = headers && headers.FEN ? new Chess(headers.FEN) : new Chess();
if (headers) {
for (const key of Object.keys(headers)) {
if (headers[key] != null && headers[key] !== "") {
chess.setHeader(key, String(headers[key]));
}
}
}
for (const m of moves) {
chess.move({
from: m.uci.slice(0, 2),
to: m.uci.slice(2, 4),
promotion: m.uci.length > 4 ? m.uci[4] : undefined,
});
}
return chess.pgn();
}
return {
computePressure,
boardDict,
frameFor,
legalMoves,
makeMove,
parsePgn,
toPgn,
};
});

View File

@ -0,0 +1,72 @@
[
{
"id": "immortal",
"name": "The Immortal Game (1851)",
"white": "Anderssen",
"black": "Kieseritzky",
"pgn": "[Event \"London\"]\n[Site \"London\"]\n[Date \"1851.06.21\"]\n[White \"Adolf Anderssen\"]\n[Black \"Lionel Kieseritzky\"]\n[Result \"1-0\"]\n\n1. e4 e5 2. f4 exf4 3. Bc4 Qh4+ 4. Kf1 b5 5. Bxb5 Nf6 6. Nf3 Qh6 7. d3 Nh5\n8. Nh4 Qg5 9. Nf5 c6 10. g4 Nf6 11. Rg1 cxb5 12. h4 Qg6 13. h5 Qg5 14. Qf3\nNg8 15. Bxf4 Qf6 16. Nc3 Bc5 17. Nd5 Qxb2 18. Bd6 Bxg1 19. e5 Qxa1+ 20. Ke2\nNa6 21. Nxg7+ Kd8 22. Qf6+ Nxf6 23. Be7# 1-0"
},
{
"id": "opera",
"name": "The Opera Game (1858)",
"white": "Morphy",
"black": "Duke of Brunswick & Count Isouard",
"pgn": "[Event \"Opera House\"]\n[Site \"Paris\"]\n[Date \"1858.11.02\"]\n[White \"Paul Morphy\"]\n[Black \"Duke of Brunswick and Count Isouard\"]\n[Result \"1-0\"]\n\n1. e4 e5 2. Nf3 d6 3. d4 Bg4 4. dxe5 Bxf3 5. Qxf3 dxe5 6. Bc4 Nf6 7. Qb3 Qe7\n8. Nc3 c6 9. Bg5 b5 10. Nxb5 cxb5 11. Bxb5+ Nbd7 12. O-O-O Rd8 13. Rxd7 Rxd7\n14. Rd1 Qe6 15. Bxd7+ Nxd7 16. Qb8+ Nxb8 17. Rd8# 1-0"
},
{
"id": "fischer-spassky-6",
"name": "Fischer vs Spassky, Game 6 (1972)",
"white": "Fischer",
"black": "Spassky",
"pgn": "[Event \"World Championship\"]\n[Site \"Reykjavik\"]\n[Date \"1972.07.23\"]\n[White \"Robert James Fischer\"]\n[Black \"Boris Spassky\"]\n[Result \"1-0\"]\n\n1. c4 e6 2. Nf3 d5 3. d4 Nf6 4. Nc3 Be7 5. Bg5 O-O 6. e3 h6 7. Bh4 b6\n8. cxd5 Nxd5 9. Bxe7 Qxe7 10. Nxd5 exd5 11. Rc1 Be6 12. Qa4 c5 13. Qa3 Rc8\n14. Bb5 a6 15. dxc5 bxc5 16. O-O Ra7 17. Be2 Nd7 18. Nd4 Qf8 19. Nxe6 fxe6\n20. e4 d4 21. f4 Qe7 22. e5 Rb8 23. Bc4 Kh8 24. Qh3 Nf8 25. b3 a5 26. f5\nexf5 27. Rxf5 Nh7 28. Rcf1 Qd8 29. Qg3 Re7 30. h4 Rbb7 31. e6 Rbc7 32. Qe5\nQe8 33. a4 Qd8 34. R1f2 Qe8 35. R2f3 Qd8 36. Bd3 Qe8 37. Qe4 Nf6 38. Rxf6\ngxf6 39. Rxf6 Kg8 40. Bc4 Kh8 41. Qf4 1-0"
},
{
"id": "deep-blue",
"name": "Kasparov vs Deep Blue, Game 2 (1997)",
"white": "Deep Blue",
"black": "Kasparov",
"pgn": "[Event \"IBM Man-Machine\"]\n[Site \"New York\"]\n[Date \"1997.05.04\"]\n[White \"Deep Blue\"]\n[Black \"Garry Kasparov\"]\n[Result \"1-0\"]\n\n1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6\n8. c3 O-O 9. h3 h6 10. d4 Re8 11. Nbd2 Bf8 12. Nf1 Bd7 13. Ng3 Na5 14. Bc2\nc5 15. b3 Nc6 16. d5 Ne7 17. Be3 Ng6 18. Qd2 Nh7 19. a4 Nh4 20. Nxh4 Qxh4\n21. Qe2 Qd8 22. b4 Qc7 23. Rec1 c4 24. Ra3 Rec8 25. Rca1 Qd8 26. f4 Nf6\n27. fxe5 dxe5 28. Qf1 Ne8 29. Qf2 Nd6 30. Bb6 Qe8 31. R3a2 Be7 32. Bc5 Bf8\n33. Nf5 Bxf5 34. exf5 f6 35. Bxd6 Bxd6 36. axb5 axb5 37. Be4 Rxa2 38. Qxa2\nQd7 39. Qa7 Rc7 40. Qb6 Rb7 41. Ra8+ Kf7 42. Qa6 Qc7 43. Qc6 Qb6+ 44. Kf1\nRb8 45. Ra6 1-0"
},
{
"id": "polgar-anand",
"name": "Polgar vs Anand (1999)",
"white": "Polgar",
"black": "Anand",
"pgn": "[Event \"Dos Hermanas\"]\n[Site \"Dos Hermanas\"]\n[Date \"1999.04.??\"]\n[White \"Judit Polgar\"]\n[Black \"Viswanathan Anand\"]\n[Result \"1-0\"]\n\n1.e4 c5 2.Nf3 d6 3.d4 cxd4 4.Nxd4 Nf6 5.Nc3 a6 6.Be3 e6 7.g4 e5 8.Nf5 g6\n9.g5 gxf5 10.exf5 d5 11.Qf3 d4 12.O-O-O Nbd7 13.Bd2 dxc3 14.Bxc3 Bg7 15.Rg1\nO-O 16.gxf6 Qxf6 17.Qe3 Kh8 18.f4 Qb6 19.Qg3 Qh6 20.Rd6 f6 21.Bd2 e4 22.Bc4\nb5 23.Be6 Ra7 24.Rc6 a5 25.Be3 Rb7 26.Bd5 Rb8 27.Rc7 b4 28.b3 Rb5 29.Bc6 Rxf5\n30.Rxc8 Rxc8 31.Bxd7 Rcc5 32.Bxf5 Rxf5 33.Rd1 Kg8 34.Qg2 1-0"
},
{
"id": "polgar-kasparov",
"name": "Polgar vs Kasparov (2002)",
"white": "Polgar",
"black": "Kasparov",
"pgn": "[Event \"Russia vs Rest of the World\"]\n[Site \"Moscow\"]\n[Date \"2002.09.09\"]\n[White \"Judit Polgar\"]\n[Black \"Garry Kasparov\"]\n[Result \"1-0\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bb5 Nf6 4.O-O Nxe4 5.d4 Nd6 6.Bxc6 dxc6 7.dxe5 Nf5\n8.Qxd8+ Kxd8 9.Nc3 h6 10.Rd1+ Ke8 11.h3 Be7 12.Ne2 Nh4 13.Nxh4 Bxh4 14.Be3\nBf5 15.Nd4 Bh7 16.g4 Be7 17.Kg2 h5 18.Nf5 Bf8 19.Kf3 Bg6 20.Rd2 hxg4+ 21.hxg4\nRh3+ 22.Kg2 Rh7 23.Kg3 f6 24.Bf4 Bxf5 25.gxf5 fxe5 26.Re1 Bd6 27.Bxe5 Kd7\n28.c4 c5 29.Bxd6 cxd6 30.Re6 Rah8 31.Rexd6+ Kc8 32.R2d5 Rh3+ 33.Kg2 Rh2+\n34.Kf3 R2h3+ 35.Ke4 b6 36.Rc6+ Kb8 37.Rd7 Rh2 38.Ke3 Rf8 39.Rcc7 Rxf5\n40.Rb7+ Kc8 41.Rdc7+ Kd8 42.Rxg7 Kc8 1-0"
},
{
"id": "hou-caruana",
"name": "Hou Yifan vs Caruana (2017)",
"white": "Hou Yifan",
"black": "Caruana",
"pgn": "[Event \"GRENKE Chess Classic\"]\n[Site \"Karlsruhe\"]\n[Date \"2017.04.15\"]\n[White \"Hou Yifan\"]\n[Black \"Fabiano Caruana\"]\n[Result \"1-0\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bb5 Nf6 4.O-O Nxe4 5.Re1 Nd6 6.Nxe5 Be7 7.Bf1 O-O 8.d4\nNf5 9.Nf3 d5 10.c3 Bd6 11.Nbd2 Nce7 12.Qc2 c6 13.Bd3 g6 14.Nf1 f6 15.h3 Rf7\n16.Bd2 Bd7 17.Re2 c5 18.dxc5 Bxc5 19.Bf4 Rc8 20.Rae1 g5 21.Ng3 Nxg3 22.Bxg3\na5 23.Qd2 a4 24.b4 axb3 25.axb3 Ng6 26.h4 gxh4 27.Nxh4 Nxh4 28.Bxh4 Qf8\n29.Qf4 Bd6 30.Qd4 Rd8 31.Re3 Bc8 32.b4 Kg7 33.Bb5 Bc7 34.Re8 Qd6 35.Bg3 Qb6\n36.Qd3 Bd7 37.Bxd7 Rdxd7 38.Qf5 Bxg3 39.Qg4+ Kh6 40.Qh3+ 1-0"
},
{
"id": "ju-lei",
"name": "Ju Wenjun vs Lei Tingjie, WCC G12 (2023)",
"white": "Ju Wenjun",
"black": "Lei Tingjie",
"pgn": "[Event \"Women's World Championship\"]\n[Site \"Shanghai/Chongqing\"]\n[Date \"2023.07.22\"]\n[White \"Ju Wenjun\"]\n[Black \"Lei Tingjie\"]\n[Result \"1-0\"]\n\n1.d4 d5 2.Nf3 Nf6 3.e3 c5 4.dxc5 e6 5.b4 a5 6.c3 axb4 7.cxb4 b6 8.Bb5+ Bd7\n9.Bxd7+ Nbxd7 10.a4 bxc5 11.b5 Qc7 12.Bb2 Bd6 13.O-O O-O 14.Nbd2 Rfc8 15.Qc2\nc4 16.Bc3 Nc5 17.a5 Nb3 18.Bxf6 Nxa1 19.Bxa1 Qxa5 20.Qc3 Qxc3 21.Bxc3 Rcb8\n22.Nd4 e5 23.Nf5 Bf8 24.Bxe5 Rxb5 25.g4 g6 26.Nd4 Rb2 27.Nb1 Bg7 28.Bxg7 Kxg7\n29.Nc3 Ra5 30.Rd1 Rb6 31.Nde2 Rb3 32.Kg2 h6 33.Kf3 f6 34.Rc1 Kf7 35.Nf4 d4\n36.exd4 g5 37.Ne2 f5 38.gxf5 Rxf5+ 39.Ke3 g4 40.Nf4 Rb8 41.d5 Rf6 42.Rc2 Ra8\n43.Nb5 Rb6 44.Nd4 Ra3+ 45.Ke4 c3 46.Nfe2 Rb2 47.Kd3 Rb1 48.Nxc3 Rh1 49.f3 gxf3\n50.Nxf3 Rf1 51.Nd4 Ke7 52.Kc4 Rf4 53.Rb2 Rh4 54.Rb7+ Kf6 55.Rb2 Ra8 56.Kc5 Rh3\n57.Ncb5 Re3 58.d6 Ke5 59.Nc6+ Ke4 60.d7 Rd3 61.Nd6+ Kf4 62.Rb8 1-0"
},
{
"id": "xie-chiburdanidze",
"name": "Xie Jun vs Chiburdanidze, WCC (1991)",
"white": "Xie Jun",
"black": "Chiburdanidze",
"pgn": "[Event \"Wch (Women)\"]\n[Site \"Manila\"]\n[Date \"1991.??.??\"]\n[White \"Xie Jun\"]\n[Black \"Maia Chiburdanidze\"]\n[Result \"1-0\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bb5 a6 4.Ba4 Nf6 5.O-O Be7 6.Re1 b5 7.Bb3 d6 8.c3 O-O\n9.h3 Nd7 10.d4 Bf6 11.a4 Bb7 12.axb5 axb5 13.Rxa8 Qxa8 14.d5 Ne7 15.Na3 c6\n16.dxc6 Bxc6 17.Qxd6 Nc8 18.Qd1 Nc5 19.Bd5 Nxe4 20.Bxc6 Qxc6 21.Qd3 Ncd6\n22.Nxb5 Qc5 23.Qe2 Ng3 24.Qe3 Qxb5 25.fxg3 e4 26.Nd4 Qb7 27.Nc2 Re8 28.Qc5\nBe5 29.g4 Qe7 30.Be3 h5 31.gxh5 Qh4 32.Bd4 Bxd4+ 33.Qxd4 Re6 34.Qd1 Nc4\n35.b3 Ne5 36.Qe2 Rf6 37.Rf1 Rxf1+ 38.Kxf1 Nd3 39.Nd4 Qf4+ 40.Kg1 Qc1+\n41.Kh2 Nc5 42.Qc2 Qf4+ 43.g3 Qg5 44.b4 Nd3 45.Qe2 f5 46.h4 Qf6 47.Qa2+ Kh7\n48.Qe6 Qxe6 49.Nxe6 e3 50.Nd4 f4 51.b5 1-0"
},
{
"id": "carlsen-nakamura",
"name": "Carlsen vs Nakamura (2010)",
"white": "Carlsen",
"black": "Nakamura",
"pgn": "[Event \"London Chess Classic\"]\n[Site \"London\"]\n[Date \"2010.12.11\"]\n[White \"Magnus Carlsen\"]\n[Black \"Hikaru Nakamura\"]\n[Result \"1-0\"]\n\n1.c4 f5 2.g3 Nf6 3.Bg2 d6 4.Nc3 g6 5.e3 Bg7 6.Nge2 O-O 7.O-O e5 8.b3 Nbd7\n9.d3 c6 10.Ba3 Qc7 11.Qd2 Re8 12.Rae1 Nc5 13.h3 e4 14.dxe4 Nfxe4 15.Qc2 Nxc3\n16.Nxc3 Be6 17.Rd1 Rad8 18.Bb2 Bf7 19.Rd2 a5 20.Rfd1 Be5 21.Ne2 a4 22.b4 Nd7\n23.Bd4 Nb6 24.Bxb6 Qxb6 25.Rb1 Qc7 26.Nd4 Rc8 27.Rc1 Qe7 28.Rd3 c5 29.bxc5\nRxc5 30.Qxa4 Rec8 31.Rb1 Rxc4 32.Qd1 b6 33.Nb5 R4c5 34.Nxd6 Bxd6 35.Rxd6\nBxa2 36.Ra1 Rc1 37.Rxc1 Rxc1 38.Rxg6+ hxg6 39.Qxc1 Qd6 40.h4 Bf7 41.h5 Kh7\n42.hxg6+ Kxg6 43.Qc2 b5 44.g4 Qe5 45.gxf5+ Kg7 46.Qe4 Qd6 47.Qh4 Bc4 48.Bf3\nQf6 49.Qxf6+ Kxf6 50.Be4 Ba2 51.f4 b4 52.Kf2 b3 53.Bd5 Kxf5 54.Kf3 Kf6 55.e4\nKg6 56.Ke3 Kh5 57.Kd4 Kg4 58.f5 Kg5 59.Ke5 1-0"
}
]

View File

@ -4,8 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chess Pressure</title>
<link rel="stylesheet" href="/static/style.css?v=21">
<link rel="stylesheet" href="/style.css?v=32">
<link rel="stylesheet" href="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.css">
<link rel="manifest" href="/manifest.json">
</head>
<body>
<header>
@ -26,17 +27,31 @@
<div class="playback">
<input type="range" id="slider" min="0" max="0" value="0">
<div class="playback-buttons">
<button id="btn-start" title="Start">&#9198;</button>
<button id="btn-prev" title="Back">&#9664;</button>
<button id="btn-play" title="Play/Pause">&#9654;</button>
<button id="btn-next" title="Forward">&#9654;</button>
<button id="btn-end" title="End">&#9197;</button>
<button id="btn-start" title="Start"><svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="2" width="2" height="12"/><polygon points="9,2 4,8 9,14"/><polygon points="15,2 10,8 15,14"/></svg></button>
<button id="btn-prev" title="Back"><svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="2" width="2" height="12"/><polygon points="14,2 6,8 14,14"/></svg></button>
<button id="btn-play" title="Play/Pause"><svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><polygon points="3,2 13,8 3,14"/></svg></button>
<button id="btn-next" title="Forward"><svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><polygon points="2,2 10,8 2,14"/><rect x="13" y="2" width="2" height="12"/></svg></button>
<button id="btn-end" title="End"><svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><polygon points="1,2 6,8 1,14"/><polygon points="7,2 12,8 7,14"/><rect x="13" y="2" width="2" height="12"/></svg></button>
</div>
<div class="pressure-mode">
<label><input type="radio" name="pmode" value="equal" checked> Equal weight</label>
<label><input type="radio" name="pmode" value="weighted"> Piece value</label>
<label><input type="checkbox" id="toggle-pieces" checked> Pieces</label>
<label><input type="checkbox" id="toggle-board"> Board</label>
<div class="pmode-toggle">
<label class="pmode-opt">
<input type="radio" name="pmode" value="equal" checked>
<span>Equal</span>
</label>
<label class="pmode-opt">
<input type="radio" name="pmode" value="weighted">
<span>Piece value</span>
</label>
</div>
<label class="toggle-row">
<input type="checkbox" id="toggle-pieces" checked>
<span>Pieces</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="toggle-board">
<span>Board</span>
</label>
</div>
</div>
</div>
@ -49,20 +64,24 @@
<button id="btn-upload" title="Upload PGN">PGN</button>
</div>
<div class="game-info" id="game-info"></div>
<div class="game-meta">
<span class="game-info" id="game-info"></span>
<span class="status-bar" id="status-bar"></span>
</div>
<div class="move-list" id="move-list"></div>
<div class="fork-controls" id="fork-controls" style="display:none">
<span class="fork-badge">Forked at move <span id="fork-point"></span></span>
<span class="fork-badge">Modified from original</span>
<button id="btn-reset-fork">Reset to original</button>
</div>
<div class="export-row">
<button id="btn-export">Export GIF</button>
<button id="btn-save" title="Save this game to your browser">Save</button>
<button id="btn-export-pgn" title="Download this game as a PGN file">Export PGN</button>
<button id="btn-export" title="Export an animated GIF">Export GIF</button>
<button id="btn-delete-saved" class="danger" title="Delete this saved game" style="display:none">Delete</button>
</div>
<div class="status-bar" id="status-bar"></div>
</div>
</main>
@ -102,10 +121,63 @@
</form>
</dialog>
<dialog id="save-dialog">
<form method="dialog">
<h2>Save game</h2>
<label>Name:
<input type="text" id="save-name" autocomplete="off">
</label>
<label>White: <input type="text" id="save-white" autocomplete="off" placeholder="White player"></label>
<label>Black: <input type="text" id="save-black" autocomplete="off" placeholder="Black player"></label>
<p id="save-note" class="export-info" style="display:none">A saved game with this name already exists — saving will overwrite it.</p>
<div class="dialog-buttons">
<button type="button" data-ok>Save</button>
<button type="button" data-cancel>Cancel</button>
</div>
</form>
</dialog>
<dialog id="export-pgn-dialog">
<form method="dialog">
<h2>Export PGN</h2>
<label>White: <input type="text" id="pgn-white" autocomplete="off" placeholder="White player"></label>
<label>Black: <input type="text" id="pgn-black" autocomplete="off" placeholder="Black player"></label>
<div class="dialog-buttons">
<button type="button" data-ok>Download</button>
<button type="button" data-cancel>Cancel</button>
</div>
</form>
</dialog>
<dialog id="confirm-dialog">
<form method="dialog">
<h2 id="confirm-title">Please confirm</h2>
<p id="confirm-msg" class="export-info"></p>
<div class="dialog-buttons">
<button type="button" data-ok>OK</button>
<button type="button" data-cancel>Cancel</button>
</div>
</form>
</dialog>
<footer>
<a href="https://github.com/mindthemath/chess-pressure">source code</a>
</footer>
<!-- PWA flag — flipped to true at build time by ENABLE_CHESS_PWA=1 (off by default). -->
<script>window.CHESS_PWA = false;</script>
<script>
if (window.CHESS_PWA && "serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("/sw.js").catch(function () {});
});
}
</script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/chess.js/0.10.3/chess.min.js"></script>
<script src="/static/gif.js"></script>
<script src="/static/app.js?v=21"></script>
<script src="/chess.min.js"></script>
<script src="/gif.js"></script>
<script src="/engine.js"></script>
<script src="/app.js?v=32"></script>
</body>
</html>

View File

@ -0,0 +1,13 @@
{
"name": "Chess Pressure",
"short_name": "Pressure",
"description": "Visualize which squares white and black control across a chess game.",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1a1a",
"theme_color": "#1a1a1a",
"icons": [
{ "src": "/img/chesspieces/wikipedia/wN.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/img/chesspieces/wikipedia/wN.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
]
}

View File

@ -118,6 +118,7 @@ main {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
#slider {
width: 100%;
@ -127,28 +128,80 @@ main {
.playback-buttons {
display: flex;
gap: 0.3rem;
justify-content: center;
width: 100%;
}
.playback-buttons button {
flex: 1 1 0;
min-width: 0;
background: var(--bg2);
border: 1px solid var(--border);
color: var(--fg);
padding: 0.4rem 0.8rem;
padding: 0.4rem 0;
min-height: 2.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.playback-buttons button:hover { background: var(--move-active); }
#btn-next { transform: scaleX(1); }
#btn-start, #btn-end {
flex: 0.6 1 0;
}
#btn-start svg, #btn-end svg {
width: 13px;
height: 13px;
}
.pressure-mode {
display: flex;
gap: 1rem;
justify-content: center;
font-size: 0.8rem;
color: var(--fg2);
flex-direction: column;
gap: 0.4rem;
}
.pmode-toggle {
display: flex;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.pmode-opt {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0.55rem 0.4rem;
font-size: 0.85rem;
color: var(--fg2);
transition: background 0.12s, color 0.12s;
border-right: 1px solid var(--border);
}
.pmode-opt:last-child { border-right: none; }
.pmode-opt input { display: none; }
.pmode-opt:has(input:checked) {
background: var(--accent);
color: var(--bg);
}
.toggle-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.6rem;
cursor: pointer;
font-size: 0.85rem;
color: var(--fg2);
min-height: 2.5rem;
border: 1px solid var(--border);
border-radius: 6px;
}
.toggle-row input[type="checkbox"] {
width: 1rem;
height: 1rem;
accent-color: var(--accent);
flex-shrink: 0;
}
.pressure-mode label { cursor: pointer; }
/* --- Game selector --- */
.game-selector {
@ -182,12 +235,20 @@ main {
.game-selector button:hover,
.fork-controls button:hover { background: var(--move-active); }
/* --- Game info --- */
/* --- Game info + status (one row) --- */
.game-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
}
.game-info {
font-size: 0.8rem;
color: var(--fg2);
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
min-width: 0;
}
/* --- Move list --- */
@ -217,7 +278,6 @@ main {
}
.move:hover { background: var(--move-bg); }
.move.active { background: var(--move-active); font-weight: 600; }
.move.forked { border-left: 2px solid var(--fork-color); }
/* --- Fork controls --- */
.fork-controls {
@ -234,9 +294,13 @@ main {
/* --- Export --- */
.export-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.4rem 0;
}
.export-row button {
flex: 1 1 auto;
background: var(--bg2);
border: 1px solid var(--border);
color: var(--fg);
@ -244,9 +308,10 @@ main {
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
width: 100%;
white-space: nowrap;
}
.export-row button:hover { background: var(--move-active); }
.export-row button.danger:hover { background: rgba(220, 50, 50, 0.25); }
dialog label {
display: block;
font-size: 0.85rem;
@ -260,6 +325,16 @@ dialog select {
padding: 0.3rem;
font-size: 0.85rem;
}
dialog input[type="text"] {
width: 100%;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.4rem;
font-size: 0.9rem;
margin-top: 0.3rem;
}
.export-info {
font-size: 0.8rem;
color: var(--fg2);
@ -282,8 +357,8 @@ dialog select {
.status-bar {
font-size: 0.8rem;
color: var(--fg2);
padding: 0.4rem 0;
border-top: 1px solid var(--border);
white-space: nowrap;
text-align: right;
}
/* --- PGN Dialog --- */
@ -328,6 +403,18 @@ dialog textarea {
}
.dialog-buttons button:hover { background: var(--move-active); }
/* --- Footer --- */
footer {
text-align: center;
padding: 1rem 0;
font-size: 0.75rem;
}
footer a {
color: var(--fg2);
text-decoration: none;
}
footer a:hover { text-decoration: underline; }
/* --- Mobile --- */
@media (max-width: 720px) {
main {

View File

@ -0,0 +1,71 @@
/* Chess Pressure service worker — opt-in offline / instant repeat-visit cache.
*
* Only registered when window.CHESS_PWA is true (gated by the ENABLE_CHESS_PWA
* build flag). When active, repeat visits are served entirely from cache and
* never hit the server which also means returning visitors won't appear in
* the Fly logs, so this is off by default.
*
* Bump CACHE on every deploy so clients pick up new assets.
*/
const CACHE = "chess-pressure-v32";
// Same-origin app shell. Cached by canonical path; the fetch handler matches
// with ignoreSearch so cache-busting query strings (?v=NN) still hit.
const CORE = [
"/",
"/index.html",
"/app.js",
"/engine.js",
"/chess.min.js",
"/gif.js",
"/gif.worker.js",
"/style.css",
"/games.json",
"/manifest.json",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE).then((c) => c.addAll(CORE)).then(() => self.skipWaiting()),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const sameOrigin = new URL(req.url).origin === self.location.origin;
// Navigations always resolve to the cached app shell (offline-first SPA).
if (req.mode === "navigate") {
event.respondWith(
caches.match("/index.html", { ignoreSearch: true }).then((r) => r || fetch(req)),
);
return;
}
// Everything else: cache-first, then network (and cache for next time).
event.respondWith(
caches.match(req, { ignoreSearch: sameOrigin }).then((cached) => {
if (cached) return cached;
return fetch(req)
.then((resp) => {
if (resp && (resp.ok || resp.type === "opaque")) {
const copy = resp.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
}
return resp;
})
.catch(() => cached);
}),
);
});