diff --git a/.github/workflows/browser-pages.yml b/.github/workflows/browser-pages.yml
new file mode 100644
index 00000000..ca380a1b
--- /dev/null
+++ b/.github/workflows/browser-pages.yml
@@ -0,0 +1,87 @@
+name: Build and deploy phone scanner
+on:
+ push:
+ branches: [browser-scanner]
+ workflow_dispatch:
+permissions:
+ contents: read
+concurrency:
+ group: gridpuzzle-browser-pages
+ cancel-in-progress: true
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v7
+ with:
+ python-version: '3.14'
+ - uses: actions/setup-node@v6
+ with:
+ node-version: '22'
+ - run: python -m pip install -e '.[dev]'
+ - name: Native adapter and full solver regressions
+ run: python -m pytest -q tests -m 'not slow'
+ - name: Geometry and model tests
+ run: node --test web/tests/*.test.js
+ - name: Build self-hosted application
+ run: python scripts/build_web.py
+ - name: Retain build for independent testing
+ uses: actions/upload-artifact@v7
+ with:
+ name: scanner-static-build
+ path: _site
+ retention-days: 7
+ - name: Install browser test runtime
+ run: |
+ npm install --no-save --package-lock=false --ignore-scripts playwright@1.63.0
+ npx playwright install --with-deps chromium webkit
+ - name: Cross-tab solver update regressions
+ run: node scripts/solver_update_regressions.cjs
+ - name: Chromium and mobile WebKit acceptance tests
+ run: node scripts/browser_smoke.cjs
+ - name: Scanner variation, review and real-newspaper regressions
+ run: |
+ node scripts/browser_regressions.cjs
+ node scripts/newspaper_regressions.cjs
+ - name: Upload screenshots and test report
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: browser-test-report
+ path: browser-artifacts
+ if-no-files-found: ignore
+ - name: Upload static Pages site
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: _site
+ configure:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pages: write
+ steps:
+ - name: Check Pages configuration
+ id: pages
+ uses: actions/configure-pages@v6
+ - name: Explain the one-time repository setting
+ if: failure()
+ run: |
+ echo '::error::Pages is not enabled for Actions. A repository owner must set Settings > Pages > Source to GitHub Actions, then re-run this job. The workflow token cannot grant itself repository administration permission.'
+ deploy:
+ needs: [build, configure]
+ runs-on: ubuntu-latest
+ permissions:
+ pages: write
+ id-token: write
+ # The default github-pages environment may be restricted to the default
+ # branch. This branch has its own Pages environment so the deployment can
+ # originate from browser-scanner without weakening that default policy.
+ environment:
+ name: gridpuzzle-browser-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Publish the tested branch artifact
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/browser-tests.yml b/.github/workflows/browser-tests.yml
new file mode 100644
index 00000000..45f4454e
--- /dev/null
+++ b/.github/workflows/browser-tests.yml
@@ -0,0 +1,23 @@
+name: Browser branch tests
+on:
+ pull_request:
+permissions:
+ contents: read
+concurrency:
+ group: browser-tests-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+jobs:
+ unit:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v6
+ with:
+ node-version: '22'
+ - name: Browser model, lifecycle and safety unit tests
+ run: node --test web/tests/*.test.js
+ - name: Check every browser module parses
+ run: find web -name '*.js' -not -path '*/vendor/*' -exec node --check {} \;
+ - name: Explain full-browser coverage
+ run: echo 'The browser-scanner push workflow is the single full Chromium/WebKit + Python deployment gate; PR CI intentionally avoids duplicating that expensive suite.'
diff --git a/.gitignore b/.gitignore
index df8c5edd..3d71f075 100644
--- a/.gitignore
+++ b/.gitignore
@@ -136,5 +136,11 @@ test.py
mypuz/
_test/
solve*.ipynb
+
+# browser scanner build and acceptance outputs
+node_modules/
+_site/
+_preview/
+browser-artifacts/
# ruff
.ruff_cache/
diff --git a/Examples/BrowserScanner/Newspaper/2026-09-07-str8ts.webp b/Examples/BrowserScanner/Newspaper/2026-09-07-str8ts.webp
new file mode 100644
index 00000000..47ddcbf8
Binary files /dev/null and b/Examples/BrowserScanner/Newspaper/2026-09-07-str8ts.webp differ
diff --git a/Examples/BrowserScanner/Newspaper/2026-09-07-sudoku.webp b/Examples/BrowserScanner/Newspaper/2026-09-07-sudoku.webp
new file mode 100644
index 00000000..61b6b2b1
Binary files /dev/null and b/Examples/BrowserScanner/Newspaper/2026-09-07-sudoku.webp differ
diff --git a/Examples/BrowserScanner/Newspaper/README.md b/Examples/BrowserScanner/Newspaper/README.md
new file mode 100644
index 00000000..d18509ab
--- /dev/null
+++ b/Examples/BrowserScanner/Newspaper/README.md
@@ -0,0 +1,11 @@
+# Real newspaper scanner fixtures
+
+These are user-provided newspaper puzzle photographs from 2026-09-07, cropped to the puzzle grid and downsampled to keep the repository and browser regression suite small. They are real newsprint images, not generated OCR fixtures.
+
+- `2026-09-07-sudoku.webp`: shaded 9×9 Sudoku with paper texture and uneven illumination.
+- `2026-09-07-str8ts.webp`: 9×9 Str8ts with solid black separators, including numbered black cells.
+- `ground-truth.json`: hand-checked printed clues and Str8ts black-cell geometry.
+
+`scripts/newspaper_regressions.cjs` runs the production scanner and self-hosted Tesseract.js pipeline against both images in Chromium and WebKit. Wrong, missing, or invented clues are acceptable only when the corresponding cell is explicitly flagged for review. The suite also enforces minimum correct-transcription counts and exact Str8ts black-cell geometry.
+
+The images live outside `web/`, so they are not shipped in the GitHub Pages application or included in the offline PWA bundle.
diff --git a/Examples/BrowserScanner/Newspaper/ground-truth.json b/Examples/BrowserScanner/Newspaper/ground-truth.json
new file mode 100644
index 00000000..c81b506e
--- /dev/null
+++ b/Examples/BrowserScanner/Newspaper/ground-truth.json
@@ -0,0 +1,37 @@
+{
+ "fixtures": [
+ {
+ "name": "2026-09-07-sudoku",
+ "image": "2026-09-07-sudoku.webp",
+ "type": "sudoku",
+ "cells": [
+ null, null, null, null, 8, null, null, 3, null,
+ 8, null, null, 7, null, null, null, null, null,
+ null, null, null, 9, null, 5, null, null, 4,
+ null, null, null, 2, 9, null, 7, null, null,
+ 7, null, null, 8, 3, null, null, 2, 5,
+ null, 1, null, null, null, null, 4, 9, null,
+ null, null, null, null, null, null, null, 5, null,
+ null, 8, null, null, 4, null, null, null, null,
+ 2, 3, null, null, null, 7, null, null, null
+ ]
+ },
+ {
+ "name": "2026-09-07-str8ts",
+ "image": "2026-09-07-str8ts.webp",
+ "type": "str8ts",
+ "black": [2, 3, 7, 8, 18, 19, 22, 23, 30, 35, 38, 42, 45, 50, 57, 58, 61, 62, 72, 73, 77, 78],
+ "cells": [
+ 9, null, "#", 6, null, null, null, "#", "#",
+ null, null, null, 1, 4, null, 6, 5, null,
+ "#", "#", null, 2, "#", "#", null, null, null,
+ 3, null, null, "#", 9, null, null, null, 5,
+ null, null, "#", 8, null, null, "#", null, null,
+ "#", null, null, null, null, 1, null, null, null,
+ null, 7, null, 9, "#", null, 3, 2, "#",
+ null, null, null, 4, null, 5, null, null, 8,
+ 7, "#", null, null, null, "#", "#", null, null
+ ]
+ }
+ ]
+}
diff --git a/benchmarks/review3_browser.json b/benchmarks/review3_browser.json
new file mode 100644
index 00000000..848f271f
--- /dev/null
+++ b/benchmarks/review3_browser.json
@@ -0,0 +1,263 @@
+[
+ {
+ "browser": "chromium",
+ "version": "153.0.8010.12",
+ "checks": [
+ "invalid imports rejected atomically; poisoned saved session recovers",
+ "390px phone layout",
+ "actual Python 3.14 WASM Sudoku solution",
+ "browser solver: killersudoku",
+ "browser solver: futoshiki",
+ "browser solver: kenken",
+ "browser solver: latinsquare",
+ "browser solver: diagonallatinsquare",
+ "browser solver: pandiagonallatinsquare",
+ "browser solver: hidato",
+ "browser solver: numbrix",
+ "browser solver: kakuro",
+ "browser solver: slitherlink",
+ "cell editing, stale-result invalidation and undo",
+ "type override preserves transcription",
+ "worker cancellation and clean restart",
+ "pagehide terminates and resets the interpreter reference",
+ "reload retains unconfirmed recognition flags",
+ "camera permission fallback",
+ "invalid scan box settings rejected before OCR and persistence",
+ "cancel during actual OCR language initialization; workers stop; fresh scan succeeds",
+ "real printed-photo OCR, auto grid size, confidence handling",
+ "photo review, exact solution and overlay",
+ "adjusted crop invalidates old photo overlay",
+ "verified offline readiness and poisoned-cache recovery",
+ "origin-offline reload and Python solve",
+ "origin-offline photo recognition"
+ ],
+ "errors": [],
+ "external": [],
+ "scan": {
+ "type": "sudoku",
+ "recognized": 30,
+ "correct": 30,
+ "unsafe": [],
+ "corrections": [],
+ "uncertain": [],
+ "cells": [
+ 5,
+ 3,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ null,
+ 1,
+ 9,
+ 5,
+ null,
+ null,
+ null,
+ null,
+ 9,
+ 8,
+ null,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ 8,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ null,
+ null,
+ 3,
+ 4,
+ null,
+ null,
+ 8,
+ null,
+ 3,
+ null,
+ null,
+ 1,
+ 7,
+ null,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ 6,
+ null,
+ null,
+ null,
+ null,
+ 2,
+ 8,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ 1,
+ 9,
+ null,
+ null,
+ 5,
+ null,
+ null,
+ null,
+ null,
+ 8,
+ null,
+ null,
+ 7,
+ 9
+ ]
+ },
+ "manualCorrections": 0,
+ "offlineMethod": "Origin server stopped and verified unreachable; cache:no-store fetch proves service-worker cache use.",
+ "ok": true
+ },
+ {
+ "browser": "webkit",
+ "version": "26.6",
+ "checks": [
+ "invalid imports rejected atomically; poisoned saved session recovers",
+ "390px phone layout",
+ "actual Python 3.14 WASM Sudoku solution",
+ "browser solver: killersudoku",
+ "browser solver: futoshiki",
+ "browser solver: kenken",
+ "browser solver: latinsquare",
+ "browser solver: diagonallatinsquare",
+ "browser solver: pandiagonallatinsquare",
+ "browser solver: hidato",
+ "browser solver: numbrix",
+ "browser solver: kakuro",
+ "browser solver: slitherlink",
+ "cell editing, stale-result invalidation and undo",
+ "type override preserves transcription",
+ "worker cancellation and clean restart",
+ "pagehide terminates and resets the interpreter reference",
+ "reload retains unconfirmed recognition flags",
+ "camera permission fallback",
+ "invalid scan box settings rejected before OCR and persistence",
+ "cancel during actual OCR language initialization; workers stop; fresh scan succeeds",
+ "real printed-photo OCR, auto grid size, confidence handling",
+ "photo review, exact solution and overlay",
+ "adjusted crop invalidates old photo overlay",
+ "verified offline readiness and poisoned-cache recovery",
+ "origin-offline reload and Python solve",
+ "origin-offline photo recognition"
+ ],
+ "errors": [],
+ "external": [],
+ "scan": {
+ "type": "sudoku",
+ "recognized": 30,
+ "correct": 30,
+ "unsafe": [],
+ "corrections": [],
+ "uncertain": [
+ 25,
+ 27
+ ],
+ "cells": [
+ 5,
+ 3,
+ null,
+ null,
+ 7,
+ null,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ null,
+ 1,
+ 9,
+ 5,
+ null,
+ null,
+ null,
+ null,
+ 9,
+ 8,
+ null,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ 8,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ null,
+ null,
+ 3,
+ 4,
+ null,
+ null,
+ 8,
+ null,
+ 3,
+ null,
+ null,
+ 1,
+ 7,
+ null,
+ null,
+ null,
+ 2,
+ null,
+ null,
+ null,
+ 6,
+ null,
+ 6,
+ null,
+ null,
+ null,
+ null,
+ 2,
+ 8,
+ null,
+ null,
+ null,
+ null,
+ 4,
+ 1,
+ 9,
+ null,
+ null,
+ 5,
+ null,
+ null,
+ null,
+ null,
+ 8,
+ null,
+ null,
+ 7,
+ 9
+ ]
+ },
+ "manualCorrections": 0,
+ "offlineMethod": "Origin server stopped and verified unreachable; cache:no-store fetch proves service-worker cache use.",
+ "ok": true
+ }
+]
\ No newline at end of file
diff --git a/benchmarks/review3_browser.md b/benchmarks/review3_browser.md
new file mode 100644
index 00000000..0cadca39
--- /dev/null
+++ b/benchmarks/review3_browser.md
@@ -0,0 +1,25 @@
+# Review-three browser safety and lifecycle verification
+
+The browser changes remain on the scanner branch. The native exact-partition and uncapped parallel-error fixes from master cb0819f are included without changing the solver's deduction hierarchy, exact matching/guarantees, branch ordering or positive-cap semantics.
+
+## Fixes
+
+- Validate dimensions before allocation and validate box/nested metadata before rendering or persistence. Invalid imported puzzles do not commit state. Malformed saved sessions fall back safely. Scan settings are captured once, checked before explicit-type OCR, and checked again before committing automatic-type results.
+- Build in a staged directory and publish only after success. Only the designated repository _site or a new/owned external directory can be replaced. Source paths, Git metadata, repository ancestors, symlinks and unowned existing directories are refused. Older unmarked output directories must be moved aside once; they are never silently deleted.
+- Ordinary asset requests, offline readiness and preparation share digest verification. Wrong cached bytes are evicted and refetched; failed network verification is never cached. Readiness checks actual assets sequentially. Cache quota failure does not break a verified online response.
+- Every scan owns an OCR host from the beginning of worker initialization. Stop can terminate raw Tesseract children while language loading is pending; cancellation acknowledgements cannot be confused with late progress. Posting failures preserve their original error and clean up workers. Recognition has a whole-task timeout as well as per-worker fallback deadlines.
+- Separate task control, edit snapshots, camera/photo flow and offline controls; format first-party sources. Compute grayscale once and run threshold/region preparation off the interface thread.
+
+## Concurrent work preserved
+
+The review branch merges scanner commit 9ea64d8 rather than overwriting it. Its grid-stroke/corner rejection is retained inside the new image worker, and guided clue review, nested input validation, recognition timeout, initial offline-button state and scanner variation tests are preserved. Confidence thresholds are not lowered.
+
+## Completed verification
+
+GitHub Actions run 34062123312 passed the combined non-slow Python suite, browser unit/lifecycle tests, build and both actual Chromium/mobile-WebKit browser suites. Earlier run 34061886549 additionally exercised the installed wheel for the same native/build code. Final normal PR CI also runs Linux/Windows wheel tests, forward compatibility, both browser suites and the combined regression suite.
+
+`review3_browser.json` records 27 checks per engine, actual Python and OCR WASM, all eleven families, malformed-import and saved-state recovery, cancellation during real language initialization followed by a fresh scan, poisoned-cache recovery, and offline reload/solving/recognition with the origin server stopped. Both engines read all 30 baseline clues correctly without corrections; WebKit still conservatively flags two correct readings for review.
+
+`review3_recognition.json` retains the separate generated scan-variation results and guided-review checks from the concurrent patch. Generated fixtures are not a representative real-world recognition benchmark. Physical-phone autofocus, camera behaviour, installation and storage eviction still require hardware testing. The 32 slow tests and full long-running corpora were not rerun. No universal speedup is claimed.
+
+Temporary source-application scripts and write-enabled validation workflows have been removed. Permanent test workflows remain read-only.
diff --git a/benchmarks/review3_recognition.json b/benchmarks/review3_recognition.json
new file mode 100644
index 00000000..7930d464
--- /dev/null
+++ b/benchmarks/review3_recognition.json
@@ -0,0 +1,123 @@
+[
+ {
+ "browser": "chromium",
+ "version": "153.0.8010.12",
+ "scans": [
+ {
+ "name": "baseline",
+ "givens": 30,
+ "correct": 30,
+ "discrepancies": [],
+ "unsafe": [],
+ "flagged": 0,
+ "elapsedMs": 1308
+ },
+ {
+ "name": "serif",
+ "givens": 30,
+ "correct": 30,
+ "discrepancies": [],
+ "unsafe": [],
+ "flagged": 4,
+ "elapsedMs": 1025
+ },
+ {
+ "name": "shifted",
+ "givens": 30,
+ "correct": 30,
+ "discrepancies": [],
+ "unsafe": [],
+ "flagged": 2,
+ "elapsedMs": 984
+ },
+ {
+ "name": "perspective-shadow",
+ "givens": 30,
+ "correct": 30,
+ "discrepancies": [],
+ "unsafe": [],
+ "flagged": 2,
+ "elapsedMs": 1001
+ },
+ {
+ "name": "four-by-four",
+ "givens": 12,
+ "correct": 12,
+ "discrepancies": [],
+ "unsafe": [],
+ "flagged": 0,
+ "elapsedMs": 798
+ }
+ ],
+ "checks": [
+ "malformed imports rejected without modifying the board",
+ "save-and-next confirms only the edited cell",
+ "blank photo rejected without inventing clues"
+ ],
+ "errors": [],
+ "ok": true
+ },
+ {
+ "browser": "webkit",
+ "version": "26.6",
+ "scans": [
+ {
+ "name": "baseline",
+ "givens": 30,
+ "correct": 30,
+ "discrepancies": [],
+ "unsafe": [],
+ "flagged": 2,
+ "elapsedMs": 1372
+ },
+ {
+ "name": "serif",
+ "givens": 30,
+ "correct": 30,
+ "discrepancies": [],
+ "unsafe": [],
+ "flagged": 2,
+ "elapsedMs": 1179
+ },
+ {
+ "name": "shifted",
+ "givens": 30,
+ "correct": 29,
+ "discrepancies": [
+ 0
+ ],
+ "unsafe": [],
+ "flagged": 4,
+ "elapsedMs": 1136
+ },
+ {
+ "name": "perspective-shadow",
+ "givens": 30,
+ "correct": 28,
+ "discrepancies": [
+ 0,
+ 4
+ ],
+ "unsafe": [],
+ "flagged": 6,
+ "elapsedMs": 1147
+ },
+ {
+ "name": "four-by-four",
+ "givens": 12,
+ "correct": 12,
+ "discrepancies": [],
+ "unsafe": [],
+ "flagged": 0,
+ "elapsedMs": 744
+ }
+ ],
+ "checks": [
+ "malformed imports rejected without modifying the board",
+ "save-and-next confirms only the edited cell",
+ "blank photo rejected without inventing clues"
+ ],
+ "errors": [],
+ "ok": true
+ }
+]
\ No newline at end of file
diff --git a/gridsolver/grid_classes/str8ts.py b/gridsolver/grid_classes/str8ts.py
new file mode 100644
index 00000000..f5ecaef9
--- /dev/null
+++ b/gridsolver/grid_classes/str8ts.py
@@ -0,0 +1,207 @@
+"""Str8ts puzzle grid: row/column uniqueness plus consecutive streets."""
+
+from collections.abc import Iterable, Sequence
+from numbers import Integral
+
+from gridsolver.abstract_grids.grid import Grid, TechniqueProfile
+from gridsolver.grid_classes.compact_grid import CompactGrid, _rectangular_rows
+from gridsolver.rules.straights import ConsecutiveSetRule
+from gridsolver.rules.unique import ElementsAtMostOnce
+
+
+type BoardCell = tuple[int, int]
+
+
+def _cell(raw: object, rows: int, cols: int, description: str) -> BoardCell:
+ if (
+ isinstance(raw, (str, bytes, bytearray))
+ or not isinstance(raw, Sequence)
+ or len(raw) != 2
+ or any(isinstance(v, bool) or not isinstance(v, Integral) for v in raw)
+ ):
+ raise TypeError(f"Invalid {description} {raw!r}")
+ row, col = map(int, raw)
+ if not (0 <= row < rows and 0 <= col < cols):
+ raise ValueError(f"{description} {(row, col)} is outside a {rows}x{cols} board")
+ return row, col
+
+
+def _cells(
+ raw: Iterable[BoardCell], rows: int, cols: int, description: str
+) -> frozenset[BoardCell]:
+ if isinstance(raw, (str, bytes, bytearray)):
+ raise TypeError(f"{description} must be coordinate pairs")
+ singular = description[:-1] if description.endswith("s") else description
+ return frozenset(_cell(item, rows, cols, singular) for item in raw)
+
+
+class Str8ts(CompactGrid):
+ """Square Str8ts board with optional numbered black cells.
+
+ White cells form horizontal/vertical streets. Every street contains a
+ consecutive set in arbitrary order. All numbered cells, including clues
+ printed on black cells, remain unique within their row and column.
+ """
+
+ technique_profile = TechniqueProfile.RULES_ONLY
+
+ def __init__(
+ self,
+ board_rows: int = 9,
+ board_cols: int | None = None,
+ *,
+ black: Iterable[BoardCell] = (),
+ numbered_black: Iterable[BoardCell] = (),
+ ) -> None:
+ for name, value in (
+ ("board_rows", board_rows),
+ ("board_cols", board_cols if board_cols is not None else board_rows),
+ ):
+ if isinstance(value, bool) or not isinstance(value, Integral):
+ raise TypeError(f"{name} must be an integer")
+ board_rows = int(board_rows)
+ board_cols = board_rows if board_cols is None else int(board_cols)
+ if board_rows != board_cols or not 2 <= board_rows <= 9:
+ raise ValueError("Str8ts requires a square board from 2x2 through 9x9")
+
+ black_cells = _cells(black, board_rows, board_cols, "black cells")
+ numbered = _cells(
+ numbered_black, board_rows, board_cols, "numbered black cells"
+ )
+ if not numbered <= black_cells:
+ raise ValueError("Numbered black cells must also be black cells")
+
+ all_cells = frozenset(
+ (row, col)
+ for row in range(board_rows)
+ for col in range(board_cols)
+ )
+ white = all_cells - black_cells
+ if not white:
+ raise ValueError("Str8ts requires at least one white cell")
+ active = tuple(sorted(white | numbered))
+ super().__init__(active, max_elem=board_rows)
+ self.board_rows = board_rows
+ self.board_cols = board_cols
+ self.black = black_cells
+ self.numbered_black = numbered
+ self.white_cells = white
+
+ rules = []
+ for row in range(board_rows):
+ group = [self.compact_cell(cell) for cell in active if cell[0] == row]
+ if len(group) > 1:
+ rules.append(ElementsAtMostOnce(self, cells=group))
+ for col in range(board_cols):
+ group = [self.compact_cell(cell) for cell in active if cell[1] == col]
+ if len(group) > 1:
+ rules.append(ElementsAtMostOnce(self, cells=group))
+
+ streets: list[tuple[BoardCell, ...]] = []
+ for row in range(board_rows):
+ run: list[BoardCell] = []
+ for col in range(board_cols + 1):
+ cell = (row, col)
+ if col < board_cols and cell in white:
+ run.append(cell)
+ else:
+ if len(run) > 1:
+ street = tuple(run)
+ streets.append(street)
+ rules.append(
+ ConsecutiveSetRule(
+ self, [self.compact_cell(x) for x in street]
+ )
+ )
+ run = []
+ for col in range(board_cols):
+ run = []
+ for row in range(board_rows + 1):
+ cell = (row, col)
+ if row < board_rows and cell in white:
+ run.append(cell)
+ else:
+ if len(run) > 1:
+ street = tuple(run)
+ streets.append(street)
+ rules.append(
+ ConsecutiveSetRule(
+ self, [self.compact_cell(x) for x in street]
+ )
+ )
+ run = []
+ self.streets = tuple(streets)
+ self.add_rules_checked(rules)
+
+ def _copy_extra_state_to(self, result: Grid) -> None:
+ super()._copy_extra_state_to(result)
+ result.board_rows = self.board_rows
+ result.board_cols = self.board_cols
+ result.black = self.black
+ result.numbered_black = self.numbered_black
+ result.white_cells = self.white_cells
+ result.streets = self.streets
+
+ @classmethod
+ def from_board(
+ cls,
+ board: Sequence[Sequence[object]],
+ *,
+ black: Iterable[BoardCell] = (),
+ ) -> "Str8ts":
+ rows = _rectangular_rows(board, "Str8ts board")
+ n = len(rows)
+ if len(rows[0]) != n:
+ raise ValueError("Str8ts requires a square board")
+ explicit_black = set(_cells(black, n, n, "black cells"))
+ givens: dict[BoardCell, int] = {}
+ for row, values in enumerate(rows):
+ for col, raw in enumerate(values):
+ key = (row, col)
+ if raw is None:
+ continue
+ if isinstance(raw, str):
+ token = raw.strip()
+ if token.upper() in {"#", "B"}:
+ explicit_black.add(key)
+ continue
+ if token in {"", ".", "0"}:
+ continue
+ if not token.isascii() or not token.isdigit():
+ raise ValueError(
+ f"Cannot parse Str8ts value {raw!r} at {key}"
+ )
+ value = int(token)
+ elif isinstance(raw, bool) or not isinstance(raw, Integral):
+ raise TypeError(
+ f"Str8ts value at {key} must be an integer, blank, or #"
+ )
+ else:
+ value = int(raw)
+ if value == 0:
+ continue
+ if not 1 <= value <= n:
+ raise ValueError(
+ f"Str8ts value {value} at {key} is outside 1..{n}"
+ )
+ givens[key] = value
+ numbered = set(givens) & explicit_black
+ grid = cls(n, n, black=explicit_black, numbered_black=numbered)
+ grid.load_key_values(givens)
+ return grid
+
+ def format_solution(self, values: Sequence[int]) -> str:
+ keyed = self.values_by_key(values)
+ lines = []
+ for row in range(self.board_rows):
+ rendered = []
+ for col in range(self.board_cols):
+ key = (row, col)
+ if key in self.black and key not in keyed:
+ rendered.append("#")
+ elif key in self.black:
+ rendered.append(f"[{keyed[key]}]")
+ else:
+ rendered.append(str(keyed[key]))
+ lines.append(" ".join(rendered))
+ return "\n".join(lines)
diff --git a/gridsolver/rules/straights.py b/gridsolver/rules/straights.py
new file mode 100644
index 00000000..e6e09577
--- /dev/null
+++ b/gridsolver/rules/straights.py
@@ -0,0 +1,82 @@
+"""Consecutive-set rule used by Str8ts streets."""
+
+from collections.abc import Iterable, MutableSequence
+
+from gridsolver.abstract_grids.gridsize_container import GridSizeContainer
+from gridsolver.rules.rules import Guarantee, InvalidGrid, Rule, RuleAlwaysSatisfied
+
+
+class ConsecutiveSetRule(Rule):
+ """Require cells to contain distinct consecutive values in any order.
+
+ A length-k street must be exactly one of the intervals
+ ``{a, ..., a+k-1}``. Candidate pruning keeps only values supported by at
+ least one perfect matching to a feasible interval. Street sizes in
+ Str8ts are at most nine, so the exact matching check is tiny.
+ """
+
+ __slots__ = ()
+
+ def __init__(self, gsz: GridSizeContainer, cells: Iterable[int]) -> None:
+ super().__init__(gsz, cells, None)
+ self.cells = tuple(sorted(self.cells))
+ if self.len_cells > self._max_elem:
+ raise ValueError("A consecutive set cannot be longer than its value domain")
+
+ @staticmethod
+ def _matching_exists(options: tuple[frozenset[int], ...]) -> bool:
+ if any(not values for values in options):
+ return False
+ order = tuple(sorted(range(len(options)), key=lambda i: len(options[i])))
+
+ def visit(position: int, used: int) -> bool:
+ if position == len(order):
+ return True
+ for value in options[order[position]]:
+ bit = 1 << (value - 1)
+ if not used & bit and visit(position + 1, used | bit):
+ return True
+ return False
+
+ return visit(0, 0)
+
+ def apply(
+ self,
+ known: MutableSequence[int],
+ candidates: tuple[set[int], ...],
+ guarantees: Iterable[Guarantee] | None = None,
+ ) -> tuple[bool, None, None]:
+ if self.len_cells <= 1:
+ raise RuleAlwaysSatisfied()
+
+ fixed = [known[cell] for cell in self.cells]
+ if all(value > 0 for value in fixed):
+ if (
+ len(set(fixed)) != self.len_cells
+ or max(fixed) - min(fixed) != self.len_cells - 1
+ ):
+ raise InvalidGrid()
+ raise RuleAlwaysSatisfied()
+
+ supported = {cell: set() for cell in self.cells}
+ for lower in range(1, self._max_elem - self.len_cells + 2):
+ interval = frozenset(range(lower, lower + self.len_cells))
+ options = tuple(
+ frozenset(candidates[cell] & interval) for cell in self.cells
+ )
+ if not self._matching_exists(options):
+ continue
+ for index, cell in enumerate(self.cells):
+ for value in options[index]:
+ forced = list(options)
+ forced[index] = frozenset((value,))
+ if self._matching_exists(tuple(forced)):
+ supported[cell].add(value)
+
+ if any(not values for values in supported.values()):
+ raise InvalidGrid()
+ for cell, values in supported.items():
+ candidates[cell].intersection_update(values)
+ if not candidates[cell]:
+ raise InvalidGrid()
+ return False, None, None
diff --git a/gridsolver/web_api.py b/gridsolver/web_api.py
new file mode 100644
index 00000000..2e13042b
--- /dev/null
+++ b/gridsolver/web_api.py
@@ -0,0 +1,255 @@
+"""Data-only browser boundary. No eval, module loading, or weaker solver mode.
+
+Coordinates at this boundary are zero-based, ROW-MAJOR flat indexes. The
+existing engine remains column-major internally; never serialize it by list().
+Null means an empty cell. '#' means blocked. Slitherlink zero is a real clue.
+"""
+from __future__ import annotations
+
+import json
+import time
+from collections import namedtuple
+from collections.abc import Mapping
+
+from gridsolver.abstract_grids.grid import Grid
+from gridsolver.grid_classes.sudoku import Sudoku
+from gridsolver.grid_classes.killer_sudoku import KillerSudoku
+from gridsolver.grid_classes.kenken import Kenken
+from gridsolver.grid_classes.futoshiki import Futoshiki
+from gridsolver.grid_classes.latins_square import (
+ LatinSquare, DiagonalLatinSquare, PandiagonalLatinSquare,
+)
+from gridsolver.grid_classes.path_puzzles import Hidato, Numbrix
+from gridsolver.grid_classes.kakuro import Kakuro
+from gridsolver.grid_classes.slitherlink import Slitherlink
+from gridsolver.grid_classes.str8ts import Str8ts
+from gridsolver.solver.solver import solve
+
+TYPES = (
+ 'sudoku', 'killersudoku', 'futoshiki', 'kenken', 'latinsquare',
+ 'diagonallatinsquare', 'pandiagonallatinsquare', 'hidato', 'numbrix',
+ 'kakuro', 'slitherlink', 'str8ts',
+)
+_ALLOWED = {'version', 'type', 'rows', 'cols', 'boxRows', 'boxCols',
+ 'cells', 'cages', 'inequalities', 'clues', 'black'}
+_Cage = namedtuple('BrowserCage', 'mytarget cells operator')
+
+
+def _integer(value, name, lo, hi):
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise ValueError(f'{name} must be an integer')
+ if not lo <= value <= hi:
+ raise ValueError(f'{name} must be between {lo} and {hi}')
+ return value
+
+
+def _array(value, name, maximum):
+ if not isinstance(value, list) or len(value) > maximum:
+ raise ValueError(f'{name} must be an array of at most {maximum} entries')
+ return value
+
+
+def _object(value, allowed, name):
+ if not isinstance(value, Mapping):
+ raise ValueError(f'{name} must be an object')
+ unknown = set(value) - allowed
+ if unknown:
+ raise ValueError(f'Unsupported {name} fields: {sorted(unknown)}')
+ return value
+
+
+def build_grid(payload):
+ """Validate JSON-shaped data and construct the existing puzzle classes."""
+ p = _object(payload, _ALLOWED, 'puzzle')
+ _integer(p.get('version', 1), 'version', 1, 1)
+ kind = p.get('type')
+ if kind not in TYPES:
+ raise ValueError('Choose a supported puzzle type; Automatic is a scanner setting')
+ rows = _integer(p.get('rows'), 'rows', 1, 25)
+ cols = _integer(p.get('cols'), 'cols', 1, 25)
+ count = rows * cols
+ raw = _array(p.get('cells'), 'cells', count)
+ if len(raw) != count:
+ raise ValueError(f'Expected exactly {count} row-major cells')
+ cages = _array(p.get('cages', []), 'cages', count)
+ inequalities = _array(p.get('inequalities', []), 'inequalities', 2 * count)
+ clues = _array(p.get('clues', []), 'clues', count)
+ black_raw = _array(p.get('black', []), 'black', count)
+ black_cells = {_integer(i, 'Black cell', 0, count - 1) for i in black_raw}
+ if len(black_cells) != len(black_raw):
+ raise ValueError('Black cells must be distinct')
+ if black_cells and kind != 'str8ts':
+ raise ValueError('Black-cell metadata is only supported for Str8ts')
+ if cages and kind not in ('killersudoku', 'kenken'):
+ raise ValueError('Cages are only supported for Killer Sudoku and KenKen')
+ if inequalities and kind != 'futoshiki':
+ raise ValueError('Inequalities require Futoshiki')
+ if clues and kind != 'kakuro':
+ raise ValueError('Across/down clues require Kakuro')
+ dense = kind not in ('hidato', 'numbrix', 'kakuro', 'slitherlink', 'str8ts')
+ if dense and rows != cols:
+ raise ValueError('This puzzle type requires a square board')
+ blocked = {i for i, v in enumerate(raw) if v == '#'}
+ if blocked and kind not in ('hidato', 'kakuro', 'str8ts'):
+ raise ValueError('Blocked cells are only supported in Hidato, Kakuro and Str8ts')
+ if kind == 'str8ts':
+ if rows != cols or rows > 9:
+ raise ValueError('Str8ts requires a square board no larger than 9x9')
+ if blocked - black_cells:
+ raise ValueError('Every # Str8ts cell must be listed in black')
+ if any(i in black_cells and raw[i] is None for i in range(count)):
+ raise ValueError('A Str8ts black cell must contain # or a numbered clue')
+ maximum = 4 if kind == 'slitherlink' else (
+ count - len(blocked) if kind in ('hidato', 'numbrix') else
+ 9 if kind == 'kakuro' else rows
+ )
+ values = []
+ for i, value in enumerate(raw):
+ if value is None or value == '#':
+ values.append(value)
+ else:
+ values.append(_integer(value, f'Cell {i + 1}',
+ 0 if kind == 'slitherlink' else 1, maximum))
+
+ def coord(i):
+ return divmod(i, cols)
+
+ if kind in ('sudoku', 'killersudoku'):
+ br = _integer(p.get('boxRows', 3), 'boxRows', 1, rows)
+ bc = _integer(p.get('boxCols', 3), 'boxCols', 1, cols)
+ if br * bc != rows or rows % br or cols % bc:
+ raise ValueError('Box dimensions must tile the board and contain one of each value')
+ cls = Sudoku if kind == 'sudoku' else KillerSudoku
+ grid = cls(rows_in_box=br, cols_in_box=bc, box_rows=rows // br, box_cols=cols // bc)
+ elif kind == 'kenken':
+ grid = Kenken(n=rows)
+ elif kind == 'futoshiki':
+ grid = Futoshiki(rows)
+ elif kind in ('latinsquare', 'diagonallatinsquare', 'pandiagonallatinsquare'):
+ grid = {'latinsquare': LatinSquare, 'diagonallatinsquare': DiagonalLatinSquare,
+ 'pandiagonallatinsquare': PandiagonalLatinSquare}[kind](rows)
+ elif kind in ('hidato', 'numbrix'):
+ cls = Hidato if kind == 'hidato' else Numbrix
+ grid = cls.from_board([values[r * cols:(r + 1) * cols] for r in range(rows)])
+ elif kind == 'slitherlink':
+ grid = Slitherlink([values[r * cols:(r + 1) * cols] for r in range(rows)])
+ elif kind == 'str8ts':
+ numbered = {i for i in black_cells if isinstance(values[i], int)}
+ grid = Str8ts(rows, cols, black=[coord(i) for i in black_cells],
+ numbered_black=[coord(i) for i in numbered])
+ grid.load_key_values({coord(i): value for i, value in enumerate(values)
+ if isinstance(value, int)})
+ else:
+ white = set(range(count)) - blocked
+ runs, seen = [], set()
+ for clue in clues:
+ _object(clue, {'cell', 'across', 'down'}, 'Kakuro clue')
+ at = _integer(clue.get('cell'), 'Clue cell', 0, count - 1)
+ if at not in blocked or at in seen:
+ raise ValueError('Each Kakuro clue must occupy a distinct blocked cell')
+ seen.add(at)
+ if clue.get('across') is None and clue.get('down') is None:
+ raise ValueError('A clue needs an across or down target')
+ r, c = coord(at)
+ for direction, dr, dc in (('across', 0, 1), ('down', 1, 0)):
+ target = clue.get(direction)
+ if target is None:
+ continue
+ target = _integer(target, f'{direction} target', 1, 45)
+ cells = []
+ rr, cc = r + dr, c + dc
+ while 0 <= rr < rows and 0 <= cc < cols and rr * cols + cc in white:
+ cells.append((rr, cc))
+ rr, cc = rr + dr, cc + dc
+ runs.append((target, cells))
+ grid = Kakuro(rows, cols, [coord(i) for i in white], runs)
+ grid.load_key_values({coord(i): v for i, v in enumerate(values)
+ if v is not None and v != '#'})
+ if kind in ('killersudoku', 'kenken'):
+ covered, entries = set(), []
+ for cage in cages:
+ _object(cage, {'cells', 'target', 'op'}, 'cage')
+ indices = _array(cage.get('cells'), 'cage cells', count)
+ if not indices:
+ raise ValueError('Cages must not be empty')
+ indices = [_integer(i, 'Cage cell', 0, count - 1) for i in indices]
+ area = set(indices)
+ if len(area) != len(indices) or covered & area:
+ raise ValueError('Cages may not overlap or repeat cells')
+ reached, pending = {indices[0]}, [indices[0]]
+ while pending:
+ r, c = coord(pending.pop())
+ for rr, cc in ((r-1, c), (r+1, c), (r, c-1), (r, c+1)):
+ nxt = rr * cols + cc
+ if 0 <= rr < rows and 0 <= cc < cols and nxt in area - reached:
+ reached.add(nxt)
+ pending.append(nxt)
+ if reached != area:
+ raise ValueError('Cage cells must be orthogonally connected')
+ covered |= area
+ target = _integer(cage.get('target'), 'Cage target', 1, 10**12)
+ op = cage.get('op', '+')
+ if op == '=' and len(area) == 1:
+ op = '+'
+ if op not in ('+', '-', '*', '/') or (kind == 'killersudoku' and op != '+'):
+ raise ValueError('Unsupported cage operator')
+ if op in ('-', '/') and len(area) != 2:
+ raise ValueError('Difference and division cages require exactly two cells')
+ cells = [coord(i) for i in indices]
+ entries.append((target, cells) if kind == 'killersudoku' else _Cage(target, cells, op))
+ if covered != set(range(count)):
+ raise ValueError(f'Cages must cover every cell; {count - len(covered)} cells need a cage')
+ if kind == 'killersudoku':
+ grid.ext_sum_cells(entries)
+ else:
+ grid.ext_target_cells(entries)
+ if kind == 'futoshiki':
+ pairs = []
+ for item in inequalities:
+ _object(item, {'less', 'greater'}, 'inequality')
+ a = _integer(item.get('less'), 'Smaller cell', 0, count - 1)
+ b = _integer(item.get('greater'), 'Larger cell', 0, count - 1)
+ pairs.append((coord(a), coord(b)))
+ grid.ext_ineqs(pairs)
+ if dense:
+ # Bypass family-specific text/cage loaders, NOT the constraint engine.
+ Grid.load(grid, [0 if value is None else value for value in values], row_wise=True)
+ return grid
+
+
+def solve_payload(payload):
+ """Finish a search capped at two; unfinished searches never imply uniqueness.
+
+ The worker owns deadlines and cancellation by terminating its interpreter.
+ Both results are independently validated by the existing solver.solve().
+ """
+ started = time.perf_counter()
+ grid = build_grid(payload)
+ solutions = solve(grid, processes=0, max_sols=2, log_level=-1)
+ rendered = []
+ rows, cols = payload['rows'], payload['cols']
+ for solution in sorted(solutions, key=lambda s: tuple(s)):
+ if isinstance(grid, Slitherlink):
+ rendered.append({'cells': list(payload['cells']),
+ 'edges': [list(edge) for edge in sorted(grid.selected_edges(solution))]})
+ elif hasattr(grid, 'values_by_key'):
+ keyed = grid.values_by_key(solution)
+ rendered.append({'cells': [keyed.get((r, c), '#') for r in range(rows) for c in range(cols)],
+ 'edges': []})
+ else:
+ rendered.append({'cells': [solution[r, c] for r in range(rows) for c in range(cols)],
+ 'edges': []})
+ return {'status': ('no-solution', 'unique', 'multiple')[len(rendered)],
+ 'solutions': rendered, 'elapsed': time.perf_counter() - started,
+ 'complete': True, 'countIsLowerBound': len(rendered) == 2}
+
+
+def solve_json(text):
+ if not isinstance(text, str) or len(text) > 200_000:
+ return json.dumps({'status': 'invalid', 'message': 'Puzzle data is too large'})
+ try:
+ payload = json.loads(text)
+ result = solve_payload(payload)
+ except (TypeError, ValueError, KeyError) as exc:
+ result = {'status': 'invalid', 'message': str(exc)}
+ return json.dumps(result)
diff --git a/scripts/browser_regressions.cjs b/scripts/browser_regressions.cjs
new file mode 100644
index 00000000..cc14876c
--- /dev/null
+++ b/scripts/browser_regressions.cjs
@@ -0,0 +1,651 @@
+/* Additional real-browser scanner and input-boundary regressions. */
+const { chromium, webkit } = require("playwright");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const { spawn } = require("node:child_process");
+const BASE = "http://127.0.0.1:8766/GridPuzzle/";
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+fs.mkdirSync("_preview", { recursive: true });
+fs.mkdirSync("browser-artifacts", { recursive: true });
+if (!fs.existsSync("_preview/GridPuzzle"))
+ fs.symlinkSync(path.resolve("_site"), "_preview/GridPuzzle", "dir");
+const server = spawn(
+ "python",
+ [
+ "-m",
+ "http.server",
+ "8766",
+ "--bind",
+ "127.0.0.1",
+ "--directory",
+ "_preview",
+ ],
+ { stdio: "ignore" },
+);
+const reports = [];
+
+// Draw known clues without reading any production OCR output. The perspective
+// fixture is a projective transform of the entire image, not just a CSS tilt.
+async function fixture(options) {
+ const { demo } = await import("./model.js");
+ const { homography, project } = await import("./geometry.js");
+ const small = options.small || options.path,
+ n = small ? 4 : 9,
+ boxRows = small ? 2 : 3,
+ boxCols = small ? 2 : 3;
+ const cells = options.path
+ ? [1, null, null, 4, 8, null, 6, null, null, 10, null, 12, 16, null, null, null]
+ : small
+ ? [1, null, 3, 4, 3, 4, null, 2, 2, 1, 4, null, null, 3, 2, 1]
+ : demo().cells;
+ const c = document.createElement("canvas");
+ c.width = c.height = 660;
+ const ctx = c.getContext("2d"),
+ cw = 576 / n;
+ ctx.fillStyle = "white";
+ if (!options.transparent) ctx.fillRect(0, 0, 660, 660);
+ ctx.strokeStyle = "black";
+ for (let i = 0; i <= n; i++) {
+ ctx.lineWidth = i % boxCols === 0 ? 5 : 2;
+ ctx.beginPath();
+ ctx.moveTo(42 + i * cw, 42);
+ ctx.lineTo(42 + i * cw, 618);
+ ctx.stroke();
+ ctx.lineWidth = i % boxRows === 0 ? 5 : 2;
+ ctx.beginPath();
+ ctx.moveTo(42, 42 + i * cw);
+ ctx.lineTo(618, 42 + i * cw);
+ ctx.stroke();
+ }
+ ctx.font = `${small ? 70 : 38}px ${options.font || "Arial"}`;
+ ctx.fillStyle = "black";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ cells.forEach((v, i) => {
+ if (v !== null)
+ ctx.fillText(
+ String(v),
+ 42 + ((i % n) + 0.5) * cw + (options.shiftX || 0),
+ 42 + (Math.floor(i / n) + 0.5) * cw + 1 + (options.shiftY || 0),
+ );
+ });
+ let output = c;
+ if (options.perspective) {
+ output = document.createElement("canvas");
+ output.width = output.height = 760;
+ const out = output.getContext("2d"),
+ image = out.createImageData(760, 760),
+ source = ctx.getImageData(0, 0, 660, 660);
+ const m = homography([
+ { x: 52, y: 95 },
+ { x: 660, y: 30 },
+ { x: 724, y: 659 },
+ { x: 19, y: 712 },
+ ]);
+ const [a, b, k, d, e, f, g, h] = m,
+ I = a * e - b * d;
+ const inverse = [
+ e - f * h,
+ k * h - b,
+ b * f - k * e,
+ f * g - d,
+ a - k * g,
+ k * d - a * f,
+ d * h - e * g,
+ b * g - a * h,
+ ].map((x) => x / I);
+ for (let y = 0; y < 760; y++)
+ for (let x = 0; x < 760; x++) {
+ const p = project(inverse, x, y),
+ at = (y * 760 + x) * 4;
+ const value =
+ p.x < 0 || p.y < 0 || p.x > 1 || p.y > 1
+ ? 255
+ : source.data[
+ (Math.round(p.y * 659) * 660 + Math.round(p.x * 659)) * 4
+ ];
+ const shaded = value * (0.6 + (0.4 * x) / 759);
+ image.data[at] = image.data[at + 1] = image.data[at + 2] = shaded;
+ image.data[at + 3] = 255;
+ }
+ out.putImageData(image, 0, 0);
+ }
+ if (options.binary) {
+ // Match the production rectification size exactly so interpolation cannot
+ // hide a threshold-zero regression by introducing intermediate greys.
+ output = document.createElement("canvas");
+ output.width = output.height = n * 100;
+ const out = output.getContext("2d");
+ out.fillStyle = "white";
+ out.fillRect(0, 0, output.width, output.height);
+ out.fillStyle = "black";
+ for (let i = 0; i <= n; i++) {
+ const at = Math.min(output.width - 1, i * 100);
+ out.fillRect(at, 0, 2, output.height);
+ out.fillRect(0, at, output.width, 2);
+ }
+ out.font = "52px Arial";
+ out.textAlign = "center";
+ out.textBaseline = "middle";
+ cells.forEach((v, i) => {
+ if (v !== null) out.fillText(String(v), (i % n + 0.5) * 100, (Math.floor(i / n) + 0.5) * 100);
+ });
+ const pixels = out.getImageData(0, 0, output.width, output.height);
+ for (let i = 0; i < pixels.data.length; i += 4)
+ pixels.data[i] = pixels.data[i + 1] = pixels.data[i + 2] = pixels.data[i] < 128 ? 0 : 255;
+ out.putImageData(pixels, 0, 0);
+ }
+ return { image: output.toDataURL("image/png").split(",")[1], cells, n, type: options.path ? "numbrix" : "sudoku" };
+}
+async function ready(page) {
+ await page.waitForSelector('body[data-ready="true"]');
+ await page.evaluate(async () => {
+ window.testState = (await import("./app.js")).getState;
+ });
+}
+async function scan(page, name, options) {
+ const f = await page.evaluate(fixture, options);
+ await page.evaluate(async () => {
+ const app = await import("./app.js"),
+ { makePuzzle } = await import("./model.js");
+ app.loadPuzzle(makePuzzle());
+ });
+ await page.selectOption("#puzzle-type", f.type === "sudoku" ? "auto" : f.type);
+ await page.locator("#auto-solve").evaluate((el) => {
+ el.checked = false;
+ });
+ const start = Date.now();
+ if (options.fallback)
+ await page.evaluate(() => {
+ window.testImageBitmap = window.createImageBitmap;
+ window.createImageBitmap = undefined;
+ });
+ try {
+ await page.setInputFiles("#photo-file", {
+ name: `${name}.png`,
+ mimeType: "image/png",
+ buffer: Buffer.from(f.image, "base64"),
+ });
+ await page.waitForFunction(
+ () =>
+ /^(Grid found\.|Set the four crop corners\.)$/.test(
+ document.querySelector("#status-text").textContent,
+ ),
+ null,
+ { timeout: 20000 },
+ );
+ } finally {
+ if (options.fallback)
+ await page.evaluate(() => {
+ window.createImageBitmap = window.testImageBitmap;
+ delete window.testImageBitmap;
+ });
+ }
+ assert.equal(
+ Number(await page.inputValue("#rows")),
+ f.n,
+ `${name}: detected rows`,
+ );
+ assert.equal(
+ Number(await page.inputValue("#cols")),
+ f.n,
+ `${name}: detected columns`,
+ );
+ if (options.layout) {
+ await page.click("#clean-view");
+ await page.selectOption("#edit-tool", "value");
+ assert.equal(await page.inputValue("#rows"), "4", "Board preserves detected rows");
+ assert.equal(await page.inputValue("#cols"), "4", "Board preserves detected columns");
+ assert.equal(await page.inputValue("#box-rows"), "2", "Board preserves detected boxes");
+ if (!await page.locator("#rows").isVisible())
+ await page.getByText("Grid size & settings", { exact: true }).click();
+ await page.fill("#rows", "");
+ await page.fill("#box-rows", "1");
+ await page.fill("#box-cols", "4");
+ await page.click("#clean-view");
+ assert.equal(await page.inputValue("#rows"), "", "incomplete layout input remains editable");
+ assert.equal(await page.inputValue("#box-rows"), "1");
+ assert.equal(await page.inputValue("#box-cols"), "4");
+ await page.fill("#rows", "4");
+ }
+ await page.click("#read-photo");
+ await page.waitForFunction(() => !window.testState().busy, null, {
+ timeout: 120000,
+ });
+ const s = await page.evaluate(() => window.testState());
+ assert.equal(s.puzzle.type, f.type, `${name}: unexpected puzzle type`);
+ const wrong = f.cells.flatMap((v, i) => (v !== s.puzzle.cells[i] ? [i] : []));
+ const unsafe = wrong.filter((i) => !s.uncertain.includes(i));
+ const given = f.cells.filter(Number.isInteger).length,
+ correct = f.cells.filter(
+ (v, i) => v !== null && v === s.puzzle.cells[i],
+ ).length;
+ const result = {
+ name,
+ givens: given,
+ correct,
+ discrepancies: wrong,
+ unsafe,
+ flagged: s.uncertain.length,
+ elapsedMs: Date.now() - start,
+ };
+ console.log(JSON.stringify(result));
+ assert.deepEqual(unsafe, [], `${name}: wrong/missing clue not flagged`);
+ assert.ok(
+ correct >= given - 2,
+ `${name}: ${correct}/${given} clues read correctly`,
+ );
+ if (["baseline", "binary", "multi-digit", "transparent", "transparent-fallback", "layout-draft"].includes(name))
+ assert.deepEqual(
+ wrong,
+ [],
+ `${name} must read every clue, not solve a weaker transcription`,
+ );
+ if (options.layout) {
+ assert.equal(s.puzzle.rows, 4);
+ assert.equal(s.puzzle.cols, 4);
+ assert.equal(s.puzzle.boxRows, 1);
+ assert.equal(s.puzzle.boxCols, 4);
+ await page.click("#undo");
+ assert.equal((await page.evaluate(() => window.testState())).puzzle.rows, 9);
+ assert.equal(await page.inputValue("#rows"), "4", "Undo preserves the pending photo layout");
+ assert.equal(await page.inputValue("#box-rows"), "1");
+ assert.equal(await page.inputValue("#box-cols"), "4");
+ }
+ return result;
+}
+async function layoutAndKeyboardRegressions(page, report) {
+ await page.selectOption("#puzzle-type", "futoshiki");
+ await page.click("#example");
+ if (!await page.locator("#rows").isVisible())
+ await page.getByText("Grid size & settings", { exact: true }).click();
+ const original = await page.evaluate(() => window.testState().puzzle);
+ assert.equal(await page.locator("#box-fields").isVisible(), false);
+ await page.selectOption("#puzzle-type", "sudoku");
+ assert.equal(await page.locator("#box-fields").isVisible(), true);
+ await page.fill("#rows", "9");
+ await page.fill("#cols", "9");
+ await page.fill("#box-rows", "3");
+ await page.fill("#box-cols", "3");
+ for (const [type, visible] of [["kenken", false], ["killersudoku", true], ["auto", true], ["sudoku", true]]) {
+ await page.selectOption("#puzzle-type", type);
+ await page.click("#clean-view");
+ assert.equal(await page.locator("#box-fields").isVisible(), visible, `${type}: next-scan box controls`);
+ assert.equal(await page.inputValue("#box-rows"), "3");
+ assert.equal(await page.inputValue("#box-cols"), "3");
+ assert.deepEqual(await page.evaluate(() => window.testState().puzzle), original,
+ "selecting the next scan type leaves the current board intact");
+ }
+ page.once("dialog", (dialog) => dialog.accept());
+ await page.click("#apply-layout");
+ const applied = await page.evaluate(() => window.testState().puzzle);
+ assert.deepEqual([applied.type, applied.rows, applied.cols, applied.boxRows, applied.boxCols],
+ ["sudoku", 9, 9, 3, 3]);
+ await page.click("#undo");
+ assert.deepEqual(await page.evaluate(() => window.testState().puzzle), original);
+ assert.equal(await page.locator("#box-fields").isVisible(), true,
+ "Undo refreshes controls for the selected scan type, not the restored board type");
+ report.checks.push("scan-type changes expose box settings and preserve layout drafts through apply and Undo");
+
+ const focusedCell = () => page.evaluate(() => document.activeElement?.getAttribute("data-cell"));
+ const selectedCells = () => page.locator(".board-cell.selected").evaluateAll(
+ (cells) => cells.map((cell) => Number(cell.dataset.cell)));
+ for (const [type, tool] of [["futoshiki", "inequality"], ["kenken", "cage"]]) {
+ await page.selectOption("#puzzle-type", type);
+ await page.click("#example");
+ await page.selectOption("#edit-tool", tool);
+ await page.locator('[data-cell="4"]').focus();
+ await page.keyboard.press("Enter");
+ assert.equal(await focusedCell(), "4", `${tool}: Enter keeps focus on the selected cell`);
+ assert.equal(await page.locator('[data-cell="4"]').getAttribute("tabindex"), "0");
+ await page.keyboard.press("ArrowRight");
+ assert.equal(await focusedCell(), "5");
+ await page.keyboard.press("Space");
+ assert.deepEqual(await selectedCells(), [4, 5]);
+ assert.equal(await focusedCell(), "5", `${tool}: Space keeps focus after extending selection`);
+ await page.keyboard.press("Space");
+ assert.deepEqual(await selectedCells(), [4]);
+ assert.equal(await focusedCell(), "5", `${tool}: deselection also keeps focus`);
+ await page.keyboard.press("Enter");
+ if (tool === "inequality") {
+ await page.click("#save-inequality");
+ const puzzle = await page.evaluate(() => window.testState().puzzle);
+ assert.ok(puzzle.inequalities.some((q) => q.less === 4 && q.greater === 5));
+ } else {
+ await page.fill("#cage-target", "7");
+ await page.selectOption("#cage-op", "+");
+ await page.click("#save-cage");
+ const puzzle = await page.evaluate(() => window.testState().puzzle);
+ assert.deepEqual(puzzle.cages.find((cage) => cage.cells.includes(4)),
+ { cells: [4, 5], target: 7, op: "+" });
+ }
+ assert.deepEqual(await selectedCells(), []);
+ }
+ report.checks.push("keyboard Enter/Space selection, arrows, deselection and saving work for cages and inequalities");
+}
+async function editorRegressions(page, report) {
+ await page.evaluate(async () => {
+ const { makePuzzle } = await import("./model.js");
+ const puzzle = makePuzzle("killersudoku", 4);
+ puzzle.cells[0] = 1;
+ puzzle.cells[2] = 3;
+ localStorage.setItem("gridpuzzle-session-v1", JSON.stringify({
+ puzzle, uncertain: [0, 1, 2, 3], cellUncertain: [0, 2],
+ cageUncertain: [0, 1, 2, 3], needsReview: true, notes: [],
+ }));
+ });
+ await page.reload();
+ await ready(page);
+ await page.selectOption("#edit-tool", "cage");
+ await page.click('[data-cell="0"]');
+ await page.click('[data-cell="1"]');
+ await page.fill("#cage-target", "3");
+ await page.click("#save-cage");
+ let review = await page.evaluate(() => window.testState());
+ assert.deepEqual(review.cellUncertain, [0, 2], "saving a cage must not confirm its digits");
+ assert.deepEqual(review.cageUncertain, [2, 3]);
+ assert.match(await page.locator('[data-cell="0"]').getAttribute("aria-label"), /check reading/);
+ await page.click("#undo");
+ review = await page.evaluate(() => window.testState());
+ assert.deepEqual(review.cageUncertain, [0, 1, 2, 3], "undo restores cage warnings");
+ await page.selectOption("#edit-tool", "value");
+ await page.click("#review-clues");
+ await page.click("#save-next");
+ assert.equal(await page.locator("#cell-title").innerText(), "Row 1 · Column 3");
+ await page.click("#close-cell");
+ review = await page.evaluate(() => window.testState());
+ assert.deepEqual(review.cellUncertain, [2]);
+ assert.deepEqual(review.cageUncertain, [0, 1, 2, 3], "saving a digit must not confirm its cage");
+ await page.reload();
+ await ready(page);
+ review = await page.evaluate(() => window.testState());
+ assert.deepEqual(review.cellUncertain, [2]);
+ assert.deepEqual(review.cageUncertain, [0, 1, 2, 3]);
+ report.checks.push("independent cell/cage review survives editing, undo and reload");
+
+ await page.selectOption("#puzzle-type", "sudoku");
+ await page.click("#example");
+ await page.click("#data-editor > summary");
+ const draft = JSON.parse(await page.inputValue("#json-data"));
+ draft.cells[2] = 4;
+ const text = JSON.stringify(draft);
+ await page.fill("#json-data", text);
+ await page.click("#clean-view");
+ assert.equal(await page.inputValue("#json-data"), text, "a view change preserves the JSON draft");
+ await page.click("#solve");
+ await page.waitForFunction(() => window.testState().result?.status === "unique", null, { timeout: 180000 });
+ assert.equal(await page.inputValue("#json-data"), text, "a solver result preserves the JSON draft");
+ assert.equal(await page.locator('[data-cell="2"] text').textContent(), "4");
+ assert.equal(await page.locator('[data-cell="2"]').getAttribute("aria-label"), "Row 1, column 3: solution 4");
+ assert.equal(await page.locator('[data-cell="0"]').getAttribute("aria-label"), "Row 1, column 1: 5");
+ await page.click("#apply-json");
+ assert.equal((await page.evaluate(() => window.testState().puzzle)).cells[2], 4);
+ assert.equal(await page.locator('[data-cell="2"]').getAttribute("aria-label"), "Row 1, column 3: 4");
+ await page.fill("#json-data", "{unfinished");
+ await page.click("#clean-view");
+ await page.click("#apply-json");
+ assert.equal(await page.inputValue("#json-data"), "{unfinished", "invalid drafts remain editable");
+ await page.click("#example");
+ assert.equal(JSON.parse(await page.inputValue("#json-data")).cells[2], null, "explicit loading starts a fresh draft");
+ report.checks.push("JSON drafts survive view/solver refreshes and apply explicitly");
+ report.checks.push("solved cells expose answers and distinguish printed clues");
+
+ await page.selectOption("#puzzle-type", "kakuro");
+ await page.click("#example");
+ for (const clue of await page.evaluate(() => window.testState().puzzle.clues)) {
+ const label = await page.locator(`[data-cell="${clue.cell}"]`).getAttribute("aria-label");
+ for (const direction of ["across", "down"])
+ if (clue[direction] != null) assert.ok(label.includes(`${direction} ${clue[direction]}`));
+ }
+ report.checks.push("Kakuro black-cell labels include their across/down targets");
+ await page.selectOption("#puzzle-type", "sudoku");
+ await page.click("#example");
+
+ // Delay the actual file-reading handler so a later UI action supersedes it.
+ await page.evaluate(() => {
+ const input = document.querySelector("#json-file");
+ const pending = window.pendingImport = { text: File.prototype.text, handler: input.onchange };
+ File.prototype.text = () => new Promise((resolve) => { pending.release = resolve; });
+ input.onchange = (event) => { pending.completed = pending.handler(event); };
+ });
+ try {
+ await page.setInputFiles("#json-file", { name: "old.json", mimeType: "application/json", buffer: Buffer.from("{invalid") });
+ await page.waitForFunction(() => Boolean(window.pendingImport.release));
+ await page.click("#example");
+ const status = await page.locator("#status").innerText();
+ await page.evaluate(async () => {
+ window.pendingImport.release("{invalid");
+ await window.pendingImport.completed;
+ });
+ assert.equal(await page.locator("#status").innerText(), status, "a superseded import cannot replace the current status");
+ } finally {
+ await page.evaluate(() => {
+ File.prototype.text = window.pendingImport.text;
+ document.querySelector("#json-file").onchange = window.pendingImport.handler;
+ delete window.pendingImport;
+ });
+ }
+ report.checks.push("superseded JSON import errors are ignored");
+}
+async function cameraOwnershipRegressions(page, report) {
+ await page.selectOption("#puzzle-type", "sudoku");
+ await page.click("#example");
+ const before = await page.evaluate(() => window.testState().puzzle);
+ // Controlled media and queued timers exercise the real application's task
+ // wiring in both engines, without depending on CI camera hardware.
+ await page.evaluate(() => {
+ // Some WebKit ports (Windows) expose no media capture at all; give
+ // them the same stub surface so the lifecycle check still runs.
+ const installedMedia = !navigator.mediaDevices;
+ if (installedMedia)
+ Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: {} });
+ const video = document.querySelector("#video"), media = navigator.mediaDevices;
+ const original = Object.getOwnPropertyDescriptor(media, "getUserMedia");
+ const timeout = window.setTimeout;
+ const camera = window.cameraTest = { stopped: 0, queued: [] };
+ Object.defineProperty(media, "getUserMedia", { configurable: true, value: async () => ({
+ getTracks: () => [{ stop() { camera.stopped++; } }],
+ }) });
+ Object.defineProperty(video, "srcObject", { configurable: true, writable: true, value: null });
+ Object.defineProperty(video, "play", { configurable: true, value: async () => {} });
+ window.setTimeout = (fn, ms, ...args) => {
+ if (ms === 800 || ms === 900) { camera.queued.push(fn); return -1; }
+ return timeout(fn, ms, ...args);
+ };
+ camera.restore = () => {
+ window.setTimeout = timeout;
+ delete video.srcObject;
+ delete video.play;
+ if (original) Object.defineProperty(media, "getUserMedia", original);
+ else delete media.getUserMedia;
+ if (installedMedia) delete navigator.mediaDevices;
+ delete window.cameraTest;
+ };
+ });
+ try {
+ for (const action of ["edit", "solve"]) {
+ await page.click("#camera");
+ await page.waitForFunction(() => document.querySelector("#status-text").textContent === "Camera ready.");
+ if (action === "edit") await page.click('[data-cell="0"]');
+ else await page.click("#solve");
+ assert.equal(await page.locator("#camera-panel").isHidden(), true, `${action} closes the camera`);
+ assert.equal(await page.evaluate(() => window.cameraTest.stopped), action === "edit" ? 1 : 2);
+ await page.evaluate(async () => {
+ const pending = window.cameraTest.queued.splice(0);
+ for (const fn of pending) await fn();
+ });
+ assert.deepEqual(await page.evaluate(() => window.testState().puzzle), before);
+ assert.equal(await page.evaluate(() => window.cameraTest.queued.length), 0, "cancelled capture never restarts");
+ if (action === "edit") await page.click("#close-cell");
+ else {
+ await page.waitForFunction(() => window.testState().result?.status === "unique", null, { timeout: 180000 });
+ }
+ }
+ } finally {
+ await page.evaluate(() => window.cameraTest.restore());
+ }
+ report.checks.push("editing and solving stop live capture, including already queued detection callbacks");
+}
+(async () => {
+ for (let i = 0; i < 60; i++) {
+ try {
+ if ((await fetch(BASE)).ok) break;
+ } catch {}
+ await sleep(100);
+ }
+ for (const [name, engine] of Object.entries({ chromium, webkit })) {
+ const browser = await engine.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: { width: 390, height: 844 },
+ isMobile: true,
+ hasTouch: true,
+ });
+ const page = await context.newPage();
+ page.setDefaultTimeout(20000);
+ const report = {
+ browser: name,
+ version: browser.version(),
+ scans: [],
+ checks: [],
+ errors: [],
+ };
+ reports.push(report);
+ page.on("pageerror", (e) => report.errors.push(e.message));
+ try {
+ await page.goto(BASE);
+ await ready(page);
+ // Exercise the real import handler, not just the pure validator. A tiny
+ // positive box increment used to hang render/conflict loops.
+ await page.click("#example");
+ const before = await page.evaluate(() => window.testState().puzzle);
+ const invalid = { ...before, boxRows: 1e-12 };
+ await page.setInputFiles("#json-file", {
+ name: "bad.json",
+ mimeType: "application/json",
+ buffer: Buffer.from(JSON.stringify(invalid)),
+ });
+ await page.waitForFunction(() =>
+ document.querySelector("#status").classList.contains("error"),
+ );
+ assert.deepEqual(
+ await page.evaluate(() => window.testState().puzzle),
+ before,
+ );
+ report.checks.push(
+ "malformed imports rejected without modifying the board",
+ );
+ // Seed saved review metadata through the documented data-only local store.
+ await page.evaluate(
+ (p) =>
+ localStorage.setItem(
+ "gridpuzzle-session-v1",
+ JSON.stringify({
+ puzzle: p,
+ uncertain: [0, 1],
+ needsReview: true,
+ notes: [],
+ }),
+ ),
+ before,
+ );
+ await page.reload();
+ await ready(page);
+ await page.click("#review-clues");
+ assert.equal(
+ await page.locator("#cell-title").innerText(),
+ "Row 1 · Column 1",
+ );
+ await page.click("#save-next");
+ assert.equal(
+ await page.locator("#cell-title").innerText(),
+ "Row 1 · Column 2",
+ );
+ assert.deepEqual(
+ (await page.evaluate(() => window.testState())).uncertain,
+ [1],
+ );
+ await page.click("#save-next");
+ assert.deepEqual(
+ (await page.evaluate(() => window.testState())).uncertain,
+ [],
+ );
+ assert.equal(
+ (await page.evaluate(() => window.testState())).needsReview,
+ true,
+ );
+ report.checks.push("save-and-next confirms only the edited cell");
+ await layoutAndKeyboardRegressions(page, report);
+ await editorRegressions(page, report);
+ await cameraOwnershipRegressions(page, report);
+ // No OCR call should be needed to reject a blank photograph.
+ const blank = await page.evaluate(() => {
+ const c = document.createElement("canvas");
+ c.width = c.height = 400;
+ const x = c.getContext("2d");
+ x.fillStyle = "white";
+ x.fillRect(0, 0, 400, 400);
+ return c.toDataURL().split(",")[1];
+ });
+ await page.setInputFiles("#photo-file", {
+ name: "blank.png",
+ mimeType: "image/png",
+ buffer: Buffer.from(blank, "base64"),
+ });
+ await page.waitForFunction(
+ () =>
+ document.querySelector("#status-text").textContent ===
+ "Set the four crop corners.",
+ );
+ await page.click("#read-photo");
+ await page.waitForFunction(() => !window.testState().busy);
+ assert.match(
+ await page.locator("#status-text").innerText(),
+ /No printed clues/,
+ );
+ report.checks.push("blank photo rejected without inventing clues");
+ for (const [label, options] of [
+ ["baseline", {}],
+ ["serif", { font: "Georgia" }],
+ ["shifted", { shiftX: 4, shiftY: -4 }],
+ ["perspective-shadow", { perspective: true }],
+ ["four-by-four", { small: true }],
+ ["layout-draft", { small: true, layout: true }],
+ ["transparent", { small: true, transparent: true }],
+ ["transparent-fallback", { small: true, transparent: true, fallback: true }],
+ ["binary", { small: true, binary: true }],
+ ["multi-digit", { path: true }],
+ ])
+ report.scans.push(await scan(page, label, options));
+ await page.screenshot({
+ path: `browser-artifacts/${name}-scanner-improved.png`,
+ fullPage: true,
+ });
+ assert.deepEqual(report.errors, []);
+ report.ok = true;
+ } catch (error) {
+ report.ok = false;
+ report.failure = error.stack;
+ console.error(name, error);
+ try {
+ report.status = await page.locator("#status").innerText();
+ await page.screenshot({
+ path: `browser-artifacts/${name}-regression-failure.png`,
+ fullPage: true,
+ });
+ } catch {}
+ } finally {
+ await browser.close();
+ fs.writeFileSync(
+ "browser-artifacts/recognition-regressions.json",
+ JSON.stringify(reports, null, 2),
+ );
+ }
+ }
+ if (reports.some((r) => !r.ok)) process.exitCode = 1;
+})()
+ .catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+ })
+ .finally(() => server.kill());
diff --git a/scripts/browser_smoke.cjs b/scripts/browser_smoke.cjs
new file mode 100644
index 00000000..8e55cfe5
--- /dev/null
+++ b/scripts/browser_smoke.cjs
@@ -0,0 +1,698 @@
+/* Real-browser tests on /GridPuzzle/, with actual Python and OCR WASM. */
+const { chromium, webkit } = require("playwright");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const { spawn } = require("node:child_process");
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+const BASE = "http://127.0.0.1:8765/GridPuzzle/",
+ SOLUTION =
+ "534678912672195348198342567859761423426853791713924856961537284287419635345286179";
+const reports = [];
+fs.mkdirSync("browser-artifacts", { recursive: true });
+fs.mkdirSync("_preview", { recursive: true });
+if (!fs.existsSync("_preview/GridPuzzle"))
+ fs.symlinkSync(path.resolve("_site"), "_preview/GridPuzzle", "dir");
+let server;
+async function startServer() {
+ server = spawn(
+ "python",
+ [
+ "-m",
+ "http.server",
+ "8765",
+ "--bind",
+ "127.0.0.1",
+ "--directory",
+ "_preview",
+ ],
+ { stdio: "ignore" },
+ );
+ for (let i = 0; i < 60; i++) {
+ try {
+ if ((await fetch(BASE, { signal: AbortSignal.timeout(2000) })).ok) return;
+ } catch {}
+ await sleep(200);
+ }
+ throw Error("The preview server did not start.");
+}
+async function stopServer() {
+ if (server) {
+ const child = server;
+ server = null;
+ await new Promise((resolve) => {
+ if (child.exitCode !== null) return resolve();
+ child.once("exit", resolve);
+ child.kill();
+ });
+ }
+ await assert.rejects(
+ fetch(BASE, { signal: AbortSignal.timeout(2000) }),
+ "The origin must actually be unreachable during offline testing.",
+ );
+}
+async function ready(page) {
+ await page.waitForSelector('body[data-ready="true"]');
+ await page.evaluate(async () => {
+ window.__gridpuzzleTestState = (await import("./app.js")).getState;
+ });
+}
+async function reloadPage(page) {
+ await Promise.all([
+ page.waitForNavigation({ waitUntil: "load", timeout: 60000 }),
+ page.evaluate(() => {
+ setTimeout(() => location.reload(), 0);
+ }),
+ ]);
+ await ready(page);
+}
+async function result(page) {
+ await page.waitForFunction(
+ () => {
+ const s = window.__gridpuzzleTestState();
+ return !s.busy && s.result !== null;
+ },
+ null,
+ { timeout: 150000 },
+ );
+ return page.evaluate(() => window.__gridpuzzleTestState().result);
+}
+async function load(page, kind) {
+ await page.evaluate(async (type) => {
+ const app = await import("./app.js"),
+ model = await import("./model.js");
+ app.loadPuzzle(model.demo(type));
+ }, kind);
+}
+async function uploadFixture(page, image) {
+ await page.evaluate(async () => {
+ const app = await import("./app.js"),
+ model = await import("./model.js");
+ app.loadPuzzle(model.makePuzzle());
+ });
+ await page.selectOption("#puzzle-type", "auto");
+ await page.setInputFiles("#photo-file", {
+ name: "printed-sudoku.png",
+ mimeType: "image/png",
+ buffer: Buffer.from(image, "base64"),
+ });
+ await page.waitForFunction(
+ () => document.querySelector("#status-text").textContent === "Grid found.",
+ );
+ assert.equal(await page.inputValue("#rows"), "9");
+ assert.equal(await page.inputValue("#cols"), "9");
+ await page.click("#read-photo");
+ await page.waitForFunction(() => !window.__gridpuzzleTestState().busy, null, {
+ timeout: 150000,
+ });
+}
+async function checkTranscription(page) {
+ return page.evaluate(async () => {
+ const model = await import("./model.js"),
+ s = window.__gridpuzzleTestState(),
+ reference = model.demo().cells;
+ return {
+ type: s.puzzle.type,
+ recognized: s.puzzle.cells.filter(Number.isInteger).length,
+ correct: s.puzzle.cells.filter((v, i) => v !== null && v === reference[i])
+ .length,
+ unsafe: s.puzzle.cells.flatMap((v, i) =>
+ v !== reference[i] && !s.uncertain.includes(i) ? [i] : [],
+ ),
+ corrections: s.puzzle.cells.flatMap((v, i) =>
+ v !== reference[i] ? [{ cell: i, value: reference[i] }] : [],
+ ),
+ uncertain: s.uncertain,
+ cells: s.puzzle.cells,
+ };
+ });
+}
+async function checkStartupCancellation(browser, image, report) {
+ const context = await browser.newContext({ serviceWorkers: "block" }),
+ page = await context.newPage();
+ let release, seen;
+ const gate = new Promise((resolve) => (release = resolve)),
+ requested = new Promise((resolve) => (seen = resolve));
+ await context.route("**/vendor/tessdata/**", async (route) => {
+ seen();
+ await gate;
+ try {
+ await route.abort();
+ } catch {}
+ });
+ try {
+ await page.goto(BASE);
+ await ready(page);
+ await page.selectOption("#puzzle-type", "sudoku");
+ await page.setInputFiles("#photo-file", {
+ name: "startup.png",
+ mimeType: "image/png",
+ buffer: Buffer.from(image, "base64"),
+ });
+ await page.waitForFunction(
+ () =>
+ document.querySelector("#status-text").textContent === "Grid found.",
+ );
+ const beforeInvalid = await page.evaluate(() => ({
+ puzzle: JSON.stringify(window.__gridpuzzleTestState().puzzle),
+ saved: localStorage.getItem("gridpuzzle-session-v1"),
+ }));
+ await page.evaluate(
+ () => (document.querySelector("#box-rows").value = "0"),
+ );
+ await page.click("#read-photo");
+ assert.equal(
+ (await page.evaluate(() => window.__gridpuzzleTestState())).busy,
+ false,
+ );
+ assert.deepEqual(
+ await page.evaluate(() => ({
+ puzzle: JSON.stringify(window.__gridpuzzleTestState().puzzle),
+ saved: localStorage.getItem("gridpuzzle-session-v1"),
+ })),
+ beforeInvalid,
+ );
+ await page.evaluate(
+ () => (document.querySelector("#box-rows").value = "3"),
+ );
+ report.checks.push(
+ "invalid scan box settings rejected before OCR and persistence",
+ );
+ await page.click("#read-photo");
+ let timeout;
+ try {
+ await Promise.race([
+ requested,
+ new Promise((_, reject) => {
+ timeout = setTimeout(
+ () => reject(Error("OCR language initialization was not observed")),
+ 30000,
+ );
+ }),
+ ]);
+ } finally {
+ clearTimeout(timeout);
+ }
+ await page.click("#stop");
+ assert.equal(
+ (await page.evaluate(() => window.__gridpuzzleTestState())).busy,
+ false,
+ );
+ await sleep(250);
+ assert.equal(
+ page
+ .workers()
+ .filter((w) => /ocr-host-worker|tesseract.*worker/.test(w.url()))
+ .length,
+ 0,
+ "OCR initialization worker leaked after Stop",
+ );
+ await context.unroute("**/vendor/tessdata/**");
+ release();
+ await uploadFixture(page, image);
+ assert.ok(
+ (await checkTranscription(page)).correct >= 24,
+ "Fresh scan after startup cancellation failed",
+ );
+ report.checks.push(
+ "cancel during actual OCR language initialization; workers stop; fresh scan succeeds",
+ );
+ } finally {
+ release();
+ await context.close();
+ }
+}
+(async () => {
+ await startServer();
+ for (const [name, engine] of Object.entries({ chromium, webkit })) {
+ const browser = await engine.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: { width: 390, height: 844 },
+ deviceScaleFactor: 1,
+ isMobile: true,
+ hasTouch: true,
+ });
+ const page = await context.newPage();
+ page.setDefaultTimeout(20000);
+ const errors = [],
+ external = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ // A Content Security Policy violation only logs; surface it as a failure.
+ // Playwright itself injects a stylesheet while capturing screenshots
+ // (WebKit reports it), so violations are ignored during our own captures.
+ let capturing = false;
+ page.on("console", (m) => {
+ if (
+ !capturing &&
+ m.type() === "error" &&
+ /Content.Security.Policy/i.test(m.text())
+ )
+ errors.push(m.text());
+ });
+ const screenshot = async (options) => {
+ capturing = true;
+ try {
+ await page.screenshot(options);
+ } finally {
+ capturing = false;
+ }
+ };
+ context.on("request", (r) => {
+ if (
+ !r.url().startsWith("http://127.0.0.1:8765/") &&
+ !r.url().startsWith("blob:") &&
+ !r.url().startsWith("data:")
+ )
+ external.push(r.url());
+ });
+ const report = {
+ browser: name,
+ version: browser.version(),
+ checks: [],
+ errors,
+ external,
+ };
+ reports.push(report);
+ try {
+ await page.goto(BASE);
+ await ready(page);
+ await page.evaluate(async () => {
+ const app = await import("./app.js"),
+ model = await import("./model.js");
+ const before = JSON.stringify(app.getState().puzzle),
+ saved = localStorage.getItem("gridpuzzle-session-v1");
+ for (const value of [1e-12, 1.5, 0, -1, "2", null]) {
+ const p = model.makePuzzle("sudoku", 4);
+ p.boxRows = value;
+ let threw = false;
+ try {
+ app.loadPuzzle(p);
+ } catch {
+ threw = true;
+ }
+ if (
+ !threw ||
+ JSON.stringify(app.getState().puzzle) !== before ||
+ localStorage.getItem("gridpuzzle-session-v1") !== saved
+ )
+ throw Error("Unsafe import committed state");
+ }
+ const p = model.makePuzzle("sudoku", 4);
+ p.boxRows = 1e-12;
+ localStorage.setItem(
+ "gridpuzzle-session-v1",
+ JSON.stringify({ puzzle: p }),
+ );
+ });
+ await reloadPage(page);
+ assert.equal(
+ (await page.evaluate(() => window.__gridpuzzleTestState())).puzzle.rows,
+ 9,
+ );
+ report.checks.push(
+ "invalid imports rejected atomically; poisoned saved session recovers",
+ );
+
+ assert.ok(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth + 1,
+ ),
+ "Phone layout overflows horizontally",
+ );
+ report.checks.push("390px phone layout");
+ await page.click("#example");
+ await page.click("#solve");
+ let solved = await result(page);
+ assert.equal(solved.status, "unique", JSON.stringify(solved));
+ assert.equal(solved.solutions[0].cells.join(""), SOLUTION);
+ report.checks.push("actual Python 3.14 WASM Sudoku solution");
+ await screenshot({
+ path: `browser-artifacts/${name}-phone.png`,
+ fullPage: true,
+ });
+ for (const kind of [
+ "killersudoku",
+ "futoshiki",
+ "kenken",
+ "latinsquare",
+ "diagonallatinsquare",
+ "pandiagonallatinsquare",
+ "hidato",
+ "numbrix",
+ "kakuro",
+ "slitherlink",
+ "str8ts",
+ ]) {
+ await load(page, kind);
+ await page.click("#solve");
+ const r = await result(page);
+ assert.ok(
+ ["unique", "multiple"].includes(r.status),
+ `${kind}: ${JSON.stringify(r)}`,
+ );
+ report.checks.push(`browser solver: ${kind}`);
+ }
+ console.log(name, "all twelve solver families passed");
+ await load(page, "sudoku");
+ await page.click("#solve");
+ await result(page);
+ await page.click('[data-cell="0"]');
+ await page.fill("#cell-value", "9");
+ await page.click("#cell-form button[type=submit]");
+ const edited = await page.evaluate(() => window.__gridpuzzleTestState());
+ assert.equal(edited.puzzle.cells[0], 9);
+ assert.equal(edited.result, null);
+ assert.notEqual(
+ await page.locator("#status").getAttribute("data-result"),
+ "unique",
+ );
+ await page.click("#undo");
+ assert.equal(
+ (await page.evaluate(() => window.__gridpuzzleTestState())).puzzle
+ .cells[0],
+ 5,
+ );
+ report.checks.push("cell editing, stale-result invalidation and undo");
+ const beforeType = (
+ await page.evaluate(() => window.__gridpuzzleTestState())
+ ).puzzle.cells;
+ await page.selectOption("#puzzle-type", "latinsquare");
+ await page.click("#use-type");
+ assert.equal(
+ (await page.evaluate(() => window.__gridpuzzleTestState())).puzzle.type,
+ "latinsquare",
+ );
+ assert.deepEqual(
+ (await page.evaluate(() => window.__gridpuzzleTestState())).puzzle
+ .cells,
+ beforeType,
+ );
+ report.checks.push("type override preserves transcription");
+ await page.evaluate(async () => {
+ const app = await import("./app.js"),
+ model = await import("./model.js");
+ app.loadPuzzle(model.makePuzzle("sudoku", 25));
+ });
+ assert.ok(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth + 1,
+ ),
+ "Large board must scroll inside its own container",
+ );
+ await page.click("#solve");
+ await page.click("#stop");
+ await sleep(250);
+ assert.equal(
+ (await page.evaluate(() => window.__gridpuzzleTestState())).busy,
+ false,
+ );
+ assert.equal(
+ (await page.evaluate(() => window.__gridpuzzleTestState())).result,
+ null,
+ );
+ await load(page, "sudoku");
+ await page.click("#solve");
+ assert.equal((await result(page)).status, "unique");
+ report.checks.push("worker cancellation and clean restart");
+ await page.evaluate(() =>
+ window.dispatchEvent(
+ new PageTransitionEvent("pagehide", { persisted: true }),
+ ),
+ );
+ await load(page, "sudoku");
+ await page.click("#solve");
+ assert.equal((await result(page)).status, "unique");
+ report.checks.push(
+ "pagehide terminates and resets the interpreter reference",
+ );
+ await page.evaluate(async () => {
+ const model = await import("./model.js");
+ localStorage.setItem(
+ "gridpuzzle-session-v1",
+ JSON.stringify({
+ puzzle: model.demo(),
+ uncertain: [0],
+ needsReview: true,
+ notes: ["Check this reading"],
+ }),
+ );
+ });
+ await reloadPage(page);
+ assert.deepEqual(
+ (await page.evaluate(() => window.__gridpuzzleTestState())).uncertain,
+ [0],
+ );
+ assert.equal(
+ (await page.evaluate(() => window.__gridpuzzleTestState())).needsReview,
+ true,
+ );
+ await page.click("#solve");
+ assert.ok(await page.locator("#confirm-dialog").isVisible());
+ await page.click("#confirm-back");
+ report.checks.push("reload retains unconfirmed recognition flags");
+ await page.evaluate(() => {
+ // Some WebKit ports (Windows) expose no media capture at all; the
+ // app must offer the same fallback for a missing or denied camera.
+ if (!navigator.mediaDevices)
+ Object.defineProperty(navigator, "mediaDevices", {
+ configurable: true,
+ value: {},
+ });
+ Object.defineProperty(navigator.mediaDevices, "getUserMedia", {
+ configurable: true,
+ value: async () => {
+ throw new DOMException(
+ "Denied in acceptance test",
+ "NotAllowedError",
+ );
+ },
+ });
+ });
+ await page.click("#camera");
+ await page.waitForSelector("#native-camera:not([hidden])");
+ report.checks.push("camera permission fallback");
+ const image = await page.evaluate(async () => {
+ const p = (await import("./model.js")).demo(),
+ c = document.createElement("canvas");
+ c.width = c.height = 660;
+ const ctx = c.getContext("2d");
+ ctx.fillStyle = "white";
+ ctx.fillRect(0, 0, 660, 660);
+ ctx.strokeStyle = "black";
+ for (let i = 0; i <= 9; i++) {
+ ctx.lineWidth = i % 3 === 0 ? 5 : 2;
+ ctx.beginPath();
+ ctx.moveTo(42 + i * 64, 42);
+ ctx.lineTo(42 + i * 64, 618);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(42, 42 + i * 64);
+ ctx.lineTo(618, 42 + i * 64);
+ ctx.stroke();
+ }
+ ctx.font = "38px Arial";
+ ctx.fillStyle = "black";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ p.cells.forEach((v, i) => {
+ if (v !== null)
+ ctx.fillText(
+ String(v),
+ 42 + ((i % 9) + 0.5) * 64,
+ 42 + (Math.floor(i / 9) + 0.5) * 64 + 1,
+ );
+ });
+ return c.toDataURL("image/png").split(",")[1];
+ });
+ await checkStartupCancellation(browser, image, report);
+ await uploadFixture(page, image);
+ const scan = await checkTranscription(page);
+ report.scan = scan;
+ console.log(name, "raw scan", JSON.stringify(scan));
+ assert.equal(scan.type, "sudoku");
+ assert.ok(
+ scan.correct >= 24,
+ `Only ${scan.correct}/30 printed clues recognized`,
+ );
+ assert.deepEqual(
+ scan.unsafe,
+ [],
+ "A wrong or missed clue was not flagged for review",
+ );
+ report.checks.push(
+ "real printed-photo OCR, auto grid size, confidence handling",
+ );
+ // Simulate the human review path through the real editor. Never silently
+ // substitute reference clues in production or report corrected OCR as raw.
+ for (const correction of scan.corrections) {
+ await page.click(`[data-cell="${correction.cell}"]`);
+ assert.ok(await page.locator("#clue-crop").isVisible());
+ await page.fill(
+ "#cell-value",
+ correction.value === null ? "" : String(correction.value),
+ );
+ await page.click("#cell-form button[type=submit]");
+ }
+ report.manualCorrections = scan.corrections.length;
+ if (
+ (await page.evaluate(() => window.__gridpuzzleTestState())).result ===
+ null
+ ) {
+ await page.click("#solve");
+ if (await page.locator("#confirm-dialog").isVisible())
+ await page.click("#confirm-solve");
+ await result(page);
+ }
+ const photoResult = await page.evaluate(
+ () => window.__gridpuzzleTestState().result,
+ );
+ assert.equal(photoResult.status, "unique");
+ assert.equal(photoResult.solutions[0].cells.join(""), SOLUTION);
+ assert.ok(
+ await page.locator("#photo-view").isEnabled(),
+ "The confirmed photo transcription must produce an overlay",
+ );
+ await page.click("#photo-view");
+ assert.ok(await page.locator("#solution-photo").isVisible());
+ await screenshot({
+ path: `browser-artifacts/${name}-overlay.png`,
+ fullPage: true,
+ });
+ report.checks.push("photo review, exact solution and overlay");
+ await page.click("#show-crop");
+ await page.focus("#crop-canvas");
+ await page.keyboard.press("ArrowRight");
+ assert.ok(await page.locator("#photo-view").isDisabled());
+ assert.ok(await page.locator("#save-photo").isHidden());
+ assert.ok(await page.locator("#solution-photo").isHidden());
+ report.checks.push("adjusted crop invalidates old photo overlay");
+ await page.locator("#prepare-offline").evaluate((el) => {
+ el.closest("details").open = true;
+ });
+ await page.click("#prepare-offline");
+ await page.waitForFunction(
+ () =>
+ document
+ .querySelector("#offline-state")
+ .textContent.startsWith("Offline assets are ready"),
+ null,
+ { timeout: 120000 },
+ );
+ assert.ok(
+ await page.evaluate(() => Boolean(navigator.serviceWorker.controller)),
+ "The service worker must control the document.",
+ );
+ const repaired = await page.evaluate(async () => {
+ // Assets are content-addressed: find model.js by its manifest digest
+ // in whichever GridPuzzle cache under this scope holds it.
+ const registration = await navigator.serviceWorker.ready;
+ const manifest = await (
+ await fetch("./assets.json", { cache: "no-store" })
+ ).json();
+ const entry = manifest.assets.find((a) => a.path === "model.js"),
+ asset = new URL(
+ `.gridpuzzle-cache/${entry.sha256}`,
+ registration.scope,
+ ).href;
+ let cache = null;
+ for (const name of await caches.keys()) {
+ if (!name.startsWith(`gridpuzzle:${registration.scope}:`)) continue;
+ const candidate = await caches.open(name);
+ if (await candidate.match(asset)) cache = candidate;
+ }
+ if (!cache) throw Error("model.js is not in any GridPuzzle cache");
+ const original = await (await cache.match(asset)).text();
+ await cache.put(asset, new Response("wrong-version bytes"));
+ const message = (type) =>
+ new Promise((resolve, reject) => {
+ const channel = new MessageChannel();
+ channel.port1.onmessage = ({ data }) => {
+ if (data.done || data.error) {
+ channel.port1.close();
+ data.error ? reject(Error(data.error)) : resolve(data);
+ }
+ };
+ registration.active.postMessage({ type }, [channel.port2]);
+ });
+ // The startup status is a presence check, so poisoned bytes still
+ // report ready; explicit preparation re-hashes, evicts and refetches.
+ const before = await message("OFFLINE_STATUS");
+ await message("PREPARE_OFFLINE");
+ const after = await message("OFFLINE_STATUS");
+ return (
+ before.ready &&
+ after.ready &&
+ (await (await cache.match(asset)).text()) === original
+ );
+ });
+ assert.ok(
+ repaired,
+ "Bad cached asset did not recover through the real service worker",
+ );
+ report.checks.push(
+ "explicit offline preparation repairs poisoned content-addressed bytes",
+ );
+
+ report.offlineMethod =
+ "Origin server stopped and verified unreachable; cache:no-store fetch proves service-worker cache use.";
+ // Stop the real server instead of relying on WebKit's synthetic offline
+ // switch, which rejected even cached document navigation in prior runs.
+ // No origin can supply a missing file while this test is running.
+ await stopServer();
+ assert.ok(
+ await page.evaluate(async () => {
+ const r = await fetch("./model.js", { cache: "no-store" });
+ return r.ok && (await r.text()).includes("export const TYPES");
+ }),
+ );
+ await reloadPage(page);
+ await load(page, "sudoku");
+ await page.click("#solve");
+ assert.equal((await result(page)).status, "unique");
+ report.checks.push("origin-offline reload and Python solve");
+ await page.goto(`${BASE}?share=1`, { waitUntil: "load" });
+ await ready(page);
+ assert.equal(await page.evaluate(() => location.search), "?share=1");
+ report.checks.push("origin-offline root navigation with a query string");
+ await uploadFixture(page, image);
+ const offlineScan = await checkTranscription(page);
+ assert.ok(offlineScan.correct >= 24);
+ assert.deepEqual(offlineScan.unsafe, []);
+ report.checks.push("origin-offline photo recognition");
+ await startServer();
+ assert.deepEqual(external, [], "App made an external runtime request");
+ assert.deepEqual(errors, [], "Browser raised uncaught errors");
+ report.ok = true;
+ console.log(name, JSON.stringify(report));
+ } catch (error) {
+ report.ok = false;
+ report.failure = error.stack;
+ console.error(name, error);
+ try {
+ await screenshot({
+ path: `browser-artifacts/${name}-failure.png`,
+ fullPage: true,
+ });
+ report.status = await page.locator("#status").innerText();
+ report.state = await page.evaluate(() =>
+ window.__gridpuzzleTestState(),
+ );
+ } catch {}
+ } finally {
+ await browser.close();
+ fs.writeFileSync(
+ "browser-artifacts/results.json",
+ JSON.stringify(reports, null, 2),
+ );
+ if (!server) await startServer();
+ }
+ }
+ if (reports.some((r) => !r.ok)) process.exitCode = 1;
+})()
+ .catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+ })
+ .finally(() => {
+ if (server) server.kill();
+ });
diff --git a/scripts/build_web.py b/scripts/build_web.py
new file mode 100644
index 00000000..b5ae5b12
--- /dev/null
+++ b/scripts/build_web.py
@@ -0,0 +1,348 @@
+#!/usr/bin/env python3
+"""Build a completely self-hosted static phone app; Python solver is unmodified.
+
+Uses immutable npm package versions for prebuilt browser assets, not a JS port
+of the solver. No npm lifecycle scripts are executed. No network calls happen
+at app runtime except requests to this site's own static files.
+"""
+
+from __future__ import annotations
+import argparse
+import base64
+from contextlib import contextmanager
+import hashlib
+import json
+import math
+from pathlib import Path
+import shutil
+import struct
+import subprocess
+import tarfile
+import tempfile
+import zlib
+import zipfile
+
+ROOT = Path(__file__).resolve().parent.parent
+# npm is npm.cmd on Windows and CreateProcess does not search for it by bare name.
+NPM = shutil.which("npm") or "npm"
+# Exact versions and registry tarball digests. The build refuses a tarball whose
+# SHA-512 differs from the pin, so a registry or proxy substitution cannot
+# reach the deployed site.
+PACKAGES = {
+ "pyodide": (
+ "314.0.6",
+ "sha512-BKDTJyIqFxC4BExLqeRS3f5xvXZIjOt8C3zGLN/Cc7tFxSwvKVhVkchQQ2AGLOtR4YrVOIFxbV8poyDOOmWwxQ==",
+ ),
+ "tesseract.js": (
+ "6.0.1",
+ "sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA==",
+ ),
+ "tesseract.js-core": (
+ "6.0.0",
+ "sha512-1Qncm/9oKM7xgrQXZXNB+NRh19qiXGhxlrR8EwFbK5SaUbPZnS5OMtP/ghtqfd23hsr1ZvZbZjeuAGcMxd/ooA==",
+ ),
+ "@tesseract.js-data/eng": (
+ "1.0.0",
+ "sha512-mbTumm6KQPUHyzTPQaF3ObXYnx0SqqfV2nabqFVQBwD6Kl7PhGSLSzOlfFTWy0P3BjghaSKA2W9GB19Jk+ZcTg==",
+ ),
+}
+
+
+def tarball_integrity(path):
+ return (
+ "sha512-"
+ + base64.b64encode(hashlib.sha512(Path(path).read_bytes()).digest()).decode()
+ )
+
+
+def package(name, version, integrity, temporary):
+ destination = temporary / name.replace("/", "_").replace("@", "")
+ destination.mkdir()
+ result = subprocess.run(
+ [
+ NPM,
+ "pack",
+ "--ignore-scripts",
+ "--json",
+ "--pack-destination",
+ str(destination),
+ f"{name}@{version}",
+ ],
+ check=True,
+ text=True,
+ capture_output=True,
+ timeout=240,
+ )
+ metadata = json.loads(result.stdout)[0]
+ tarball = destination / metadata["filename"]
+ actual = tarball_integrity(tarball)
+ if actual != integrity:
+ raise ValueError(
+ f"{name}@{version} tarball integrity {actual} does not match the pinned {integrity}"
+ )
+ with tarfile.open(tarball) as archive:
+ archive.extractall(destination, filter="data")
+ return destination / "package", integrity
+
+
+def copy(source, destination):
+ if not source.is_file():
+ raise FileNotFoundError(f"Required browser asset missing: {source}")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(source, destination)
+
+
+def icon(size, path):
+ """Opaque PNG icon with a mask-safe grid/check mark, using only stdlib."""
+ ink = (18, 59, 59)
+ light = (220, 236, 224)
+ mint = (125, 209, 170)
+ gold = (243, 202, 118)
+
+ def segment_distance(x, y, a, b):
+ dx, dy = b[0] - a[0], b[1] - a[1]
+ t = max(0, min(1, ((x - a[0]) * dx + (y - a[1]) * dy) / (dx * dx + dy * dy)))
+ return math.hypot(x - a[0] - t * dx, y - a[1] - t * dy)
+
+ raw = bytearray()
+ for yy in range(size):
+ raw.append(0)
+ for xx in range(size):
+ x, y = xx / size, yy / size
+ color = ink
+ if 0.22 < x < 0.78 and 0.22 < y < 0.78:
+ if (
+ min(abs(x - z) for z in (0.23, 0.41, 0.59, 0.77)) < 0.012
+ or min(abs(y - z) for z in (0.23, 0.41, 0.59, 0.77)) < 0.012
+ ):
+ color = light
+ elif 0.43 < x < 0.57 and 0.43 < y < 0.57:
+ color = mint
+ if (
+ min(
+ segment_distance(x, y, (0.58, 0.68), (0.65, 0.75)),
+ segment_distance(x, y, (0.65, 0.75), (0.79, 0.55)),
+ )
+ < 0.025
+ ):
+ color = gold
+ raw.extend((*color, 255))
+
+ def chunk(kind, data):
+ return (
+ struct.pack("!I", len(data))
+ + kind
+ + data
+ + struct.pack("!I", zlib.crc32(kind + data) & 0xFFFFFFFF)
+ )
+
+ png = (
+ b"\x89PNG\r\n\x1a\n"
+ + chunk(b"IHDR", struct.pack("!2I5B", size, size, 8, 6, 0, 0, 0))
+ + chunk(b"IDAT", zlib.compress(bytes(raw), 9))
+ + chunk(b"IEND", b"")
+ )
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(png)
+
+
+_OUTPUT_MARKER = ".gridpuzzle-output"
+_OUTPUT_KIND = "GridPuzzle static output v1\n"
+
+
+def validate_output(root, output):
+ """Never clean source paths, links or directories not owned by this builder."""
+ root = Path(root).resolve()
+ raw = root / output
+ if raw.is_symlink():
+ raise ValueError("Build output must not be a symbolic link")
+ out = raw.resolve()
+ if out == root or root.is_relative_to(out):
+ raise ValueError("Build output must not contain the repository")
+ if out.is_relative_to(root) and out != root / "_site":
+ raise ValueError(
+ "Inside the repository only _site may be used; choose a new external directory for custom output"
+ )
+ if out.exists():
+ marker = out / _OUTPUT_MARKER
+ if (
+ not out.is_dir()
+ or marker.is_symlink()
+ or not marker.is_file()
+ or marker.stat().st_size > 128
+ or marker.read_text() != _OUTPUT_KIND
+ ):
+ raise ValueError(
+ "Refusing to replace an unowned output directory; move it aside and retry"
+ )
+ return out
+
+
+@contextmanager
+def build_destination(root, output):
+ """Build in isolation; failed builds leave the last good output intact."""
+ out = validate_output(root, output)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ stage = Path(tempfile.mkdtemp(prefix="." + out.name + "-stage-", dir=out.parent))
+ backup = None
+ try:
+ (stage / _OUTPUT_MARKER).write_text(_OUTPUT_KIND)
+ yield stage
+ # Recheck after the build, before any rename (including ownership).
+ validate_output(root, output)
+ if out.exists():
+ backup = (
+ Path(
+ tempfile.mkdtemp(prefix="." + out.name + "-backup-", dir=out.parent)
+ )
+ / "previous"
+ )
+ out.replace(backup)
+ try:
+ stage.replace(out)
+ except BaseException:
+ if backup is not None:
+ backup.replace(out)
+ backup.parent.rmdir()
+ raise
+ if backup is not None:
+ shutil.rmtree(backup.parent)
+ finally:
+ if stage.exists():
+ shutil.rmtree(stage)
+ # Never delete a backup after a failed restoration.
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--output", default="_site")
+ args = parser.parse_args()
+ with build_destination(ROOT, args.output) as out:
+ build(out)
+
+
+def build(out):
+ commit = subprocess.check_output(
+ ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
+ ).strip()
+ build = commit[:12]
+ for source in (ROOT / "web").iterdir():
+ if source.is_file() and source.suffix in (
+ ".html",
+ ".css",
+ ".js",
+ ".svg",
+ ".webmanifest",
+ ):
+ text = source.read_text(encoding="utf-8").replace("__BUILD_ID__", build)
+ if source.name == "solver-worker.js":
+ text = text.replace("solver.zip", f"solver.{build}.zip")
+ (out / source.name).write_text(text, encoding="utf-8", newline="\n")
+ (out / ".nojekyll").touch()
+ # Include every original core module byte-for-byte, and its license.
+ with zipfile.ZipFile(
+ out / f"solver.{build}.zip", "w", zipfile.ZIP_DEFLATED
+ ) as archive:
+ for source in sorted((ROOT / "gridsolver").rglob("*.py")):
+ name = source.relative_to(ROOT).as_posix()
+ entry = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0))
+ entry.compress_type = zipfile.ZIP_DEFLATED
+ archive.writestr(entry, source.read_bytes())
+ archive.writestr("LICENSE", (ROOT / "LICENSE").read_bytes())
+ provenance = []
+ with tempfile.TemporaryDirectory() as temporary:
+ for name, (version, pinned) in PACKAGES.items():
+ source, integrity = package(name, version, pinned, Path(temporary))
+ provenance.append(
+ {"package": name, "version": version, "integrity": integrity}
+ )
+ if name == "pyodide":
+ # Since 314.0 the Emscripten bootstrap is a native ES module.
+ for file in (
+ "pyodide.mjs",
+ "pyodide.js",
+ "pyodide.asm.mjs",
+ "pyodide.asm.wasm",
+ "python_stdlib.zip",
+ "pyodide-lock.json",
+ ):
+ copy(source / file, out / "vendor/pyodide" / file)
+ elif name == "tesseract.js":
+ for file in ("tesseract.min.js", "worker.min.js"):
+ copy(source / "dist" / file, out / "vendor/tesseract" / file)
+ elif name == "tesseract.js-core":
+ # The OCR host runs Tesseract in LSTM-only mode, so the legacy-engine
+ # core variants would only enlarge the offline download.
+ cores = sorted(source.glob("*lstm*.wasm*"))
+ if len(cores) != 4:
+ raise FileNotFoundError(
+ f"Expected the plain and SIMD LSTM cores with their loaders: {cores}"
+ )
+ for file in cores:
+ copy(file, out / "vendor/tesseract-core" / file.name)
+ else:
+ candidates = sorted(source.rglob("eng.traineddata.gz"))
+ preferred = [p for p in candidates if "best_int" in p.as_posix()]
+ if not preferred:
+ raise FileNotFoundError(
+ f"English best_int model not found: {candidates}"
+ )
+ copy(preferred[0], out / "vendor/tessdata/eng.traineddata.gz")
+ for license_path in source.glob("*LICENSE*"):
+ if license_path.is_file():
+ copy(
+ license_path,
+ out
+ / "licenses"
+ / f"{name.replace('/', '_').replace('@', '')}-{license_path.name}",
+ )
+ for name, size in [
+ ("apple-touch-icon.png", 180),
+ ("icon-192.png", 192),
+ ("icon-512.png", 512),
+ ("maskable-512.png", 512),
+ ]:
+ icon(size, out / "icons" / name)
+ copy(ROOT / "LICENSE", out / "LICENSE.txt")
+ (out / "build-info.json").write_text(
+ json.dumps({"commit": commit, "build": build, "packages": provenance}, indent=2)
+ + "\n",
+ encoding="utf-8",
+ newline="\n",
+ )
+ (out / "THIRD_PARTY_NOTICES.txt").write_text(
+ "GridPuzzle is AGPL-3.0-only. Source: https://github.com/senegrom/GridPuzzle/tree/browser-scanner\nBrowser dependencies are self-hosted, version-pinned, and retain their supplied licenses.\n"
+ + json.dumps(provenance, indent=2)
+ + "\n",
+ encoding="utf-8",
+ newline="\n",
+ )
+ assets = []
+ for source in sorted(out.rglob("*")):
+ if source.is_file() and source.name not in (
+ "sw.js",
+ ".nojekyll",
+ _OUTPUT_MARKER,
+ ):
+ data = source.read_bytes()
+ assets.append(
+ {
+ "path": source.relative_to(out).as_posix(),
+ "bytes": len(data),
+ "sha256": hashlib.sha256(data).hexdigest(),
+ }
+ )
+ (out / "assets.json").write_text(
+ json.dumps({"build": build, "assets": assets}, separators=(",", ":")) + "\n",
+ encoding="utf-8",
+ newline="\n",
+ )
+ print(
+ f"Built {build}: {len(assets)} offline assets, {sum(a['bytes'] for a in assets) / 1024**2:.1f} MiB",
+ flush=True,
+ )
+ print(json.dumps(provenance, indent=2), flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/newspaper_regressions.cjs b/scripts/newspaper_regressions.cjs
new file mode 100644
index 00000000..bd9bd8ca
--- /dev/null
+++ b/scripts/newspaper_regressions.cjs
@@ -0,0 +1,134 @@
+/* Real user-supplied newspaper photographs: OCR/structure safety regression. */
+const { chromium, webkit } = require("playwright");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const { spawn } = require("node:child_process");
+
+const BASE = "http://127.0.0.1:8768/GridPuzzle/";
+const ROOT = path.resolve("Examples/BrowserScanner/Newspaper");
+const TRUTH = JSON.parse(fs.readFileSync(path.join(ROOT, "ground-truth.json"), "utf8"));
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+fs.mkdirSync("_preview", { recursive: true });
+fs.mkdirSync("browser-artifacts", { recursive: true });
+if (!fs.existsSync("_preview/GridPuzzle"))
+ fs.symlinkSync(path.resolve("_site"), "_preview/GridPuzzle", "dir");
+const server = spawn(
+ "python",
+ ["-m", "http.server", "8768", "--bind", "127.0.0.1", "--directory", "_preview"],
+ { stdio: "ignore" },
+);
+const reports = [];
+
+async function ready(page) {
+ await page.waitForSelector('body[data-ready="true"]');
+}
+
+async function scan(page, fixture) {
+ const jpeg = fs.readFileSync(path.join(ROOT, fixture.image)).toString("base64");
+ return page.evaluate(async ({ jpeg, type }) => {
+ const image = new Image();
+ image.src = `data:image/jpeg;base64,${jpeg}`;
+ await image.decode();
+ const canvas = document.createElement("canvas");
+ canvas.width = image.naturalWidth;
+ canvas.height = image.naturalHeight;
+ canvas.getContext("2d").drawImage(image, 0, 0);
+ const { Scanner } = await import("./scanner.js");
+ const scanner = new Scanner();
+ try {
+ return await scanner.read(
+ canvas,
+ [
+ { x: 0, y: 0 },
+ { x: canvas.width - 1, y: 0 },
+ { x: canvas.width - 1, y: canvas.height - 1 },
+ { x: 0, y: canvas.height - 1 },
+ ],
+ type,
+ 9,
+ 9,
+ );
+ } finally {
+ scanner.cancel();
+ }
+ }, { jpeg, type: fixture.type });
+}
+
+function assess(fixture, scanResult) {
+ const expected = fixture.cells,
+ actual = scanResult.puzzle.cells,
+ uncertain = new Set(scanResult.uncertain),
+ wrong = expected.flatMap((value, i) => (actual[i] === value ? [] : [i])),
+ unsafe = wrong.filter((i) => !uncertain.has(i)),
+ printed = expected.filter(Number.isInteger).length,
+ correctPrinted = expected.filter(
+ (value, i) => Number.isInteger(value) && actual[i] === value,
+ ).length;
+ assert.deepEqual(unsafe, [], `${fixture.name}: wrong/invented clues must require review`);
+ assert.equal(scanResult.puzzle.type, fixture.type);
+ if (fixture.type === "str8ts") {
+ assert.deepEqual(scanResult.puzzle.black, fixture.black, "Str8ts black-cell geometry changed");
+ assert.ok(scanResult.needsReview, "Str8ts scan must remain review-gated");
+ assert.ok(correctPrinted >= 18, `${fixture.name}: only ${correctPrinted}/${printed} printed values read`);
+ } else {
+ assert.deepEqual(scanResult.puzzle.black || [], [], "Shaded Sudoku cells became structural black cells");
+ assert.ok(correctPrinted >= 22, `${fixture.name}: only ${correctPrinted}/${printed} printed values read`);
+ }
+ return {
+ name: fixture.name,
+ type: fixture.type,
+ printed,
+ correctPrinted,
+ wrong,
+ unsafe,
+ uncertain: scanResult.uncertain,
+ black: scanResult.puzzle.black || [],
+ };
+}
+
+(async () => {
+ for (let i = 0; i < 60; i++) {
+ try {
+ if ((await fetch(BASE)).ok) break;
+ } catch {}
+ await sleep(100);
+ }
+ for (const [name, engine] of Object.entries({ chromium, webkit })) {
+ const browser = await engine.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: { width: 390, height: 844 },
+ isMobile: true,
+ hasTouch: true,
+ });
+ const page = await context.newPage();
+ page.setDefaultTimeout(20000);
+ const report = { browser: name, version: browser.version(), scans: [], errors: [] };
+ reports.push(report);
+ page.on("pageerror", (error) => report.errors.push(error.message));
+ try {
+ await page.goto(BASE);
+ await ready(page);
+ for (const fixture of TRUTH.fixtures)
+ report.scans.push(assess(fixture, await scan(page, fixture)));
+ assert.deepEqual(report.errors, []);
+ report.ok = true;
+ } catch (error) {
+ report.ok = false;
+ report.failure = error.stack;
+ console.error(name, error);
+ } finally {
+ await browser.close();
+ fs.writeFileSync(
+ "browser-artifacts/newspaper-regressions.json",
+ JSON.stringify(reports, null, 2),
+ );
+ }
+ }
+ if (reports.some((report) => !report.ok)) process.exitCode = 1;
+})()
+ .catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+ })
+ .finally(() => server.kill());
diff --git a/scripts/solver_update_regressions.cjs b/scripts/solver_update_regressions.cjs
new file mode 100644
index 00000000..0c699639
--- /dev/null
+++ b/scripts/solver_update_regressions.cjs
@@ -0,0 +1,141 @@
+/* Real service-worker clients and cache storage across a two-tab update. */
+const { chromium, webkit } = require("playwright");
+const { createServer } = require("node:http");
+const { createHash } = require("node:crypto");
+const fs = require("node:fs");
+const assert = require("node:assert/strict");
+const source = fs.readFileSync("web/sw.js", "utf8");
+const first = "111111111111", second = "222222222222";
+const reports = [];
+let build = first, requests = [];
+function files() {
+ return {
+ "index.html": "
Solver update regression",
+ "solver-worker.js": `let release;
+ self.onmessage=async({data})=>{
+ try {
+ if(data==="release") { release(); return; }
+ if(data==="start") await new Promise(resolve=>{
+ release=resolve;
+ self.postMessage({loading:true});
+ });
+ self.postMessage({debug:{controllerState:self.navigator.serviceWorker?.controller?.state, url:self.location.href}});
+ const response=await fetch("./solver.${build}.zip");
+ self.postMessage({status:response.status,body:await response.text()});
+ } catch(error) { self.postMessage({error:error.message}); }
+ };`,
+ [`solver.${build}.zip`]: `verified solver ${build}`,
+ };
+}
+const server = createServer((request, response) => {
+ const pathname = new URL(request.url, "http://localhost").pathname;
+ const path = pathname.replace(/^\/GridPuzzle\//, "") || "index.html";
+ requests.push({ build, path });
+ const assets = files();
+ let body;
+ if (path === "sw.js") body = source.replace("__BUILD_ID__", build);
+ else if (path === "assets.json") body = JSON.stringify({ build, assets: Object.entries(assets).map(([path, data]) => ({
+ path, bytes: Buffer.byteLength(data), sha256: createHash("sha256").update(data).digest("hex"),
+ })) });
+ else body = assets[path];
+ if (body === undefined) { response.writeHead(404); response.end("Not found"); return; }
+ response.writeHead(200, {
+ "content-type": path.endsWith(".js") ? "application/javascript" : path.endsWith(".html") ? "text/html" : "application/octet-stream",
+ "cache-control": "no-store",
+ });
+ response.end(body);
+});
+async function stopServer() {
+ if (!server.listening) return;
+ await new Promise((resolve) => {
+ server.close(resolve);
+ server.closeAllConnections();
+ });
+}
+(async () => {
+ for (const [name, engine] of Object.entries({ chromium, webkit })) {
+ build = first;
+ requests = [];
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const base = `http://127.0.0.1:${server.address().port}/GridPuzzle/`;
+ const browser = await engine.launch({ headless: true });
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ const report = { browser: name, version: browser.version(), errors: [] };
+ reports.push(report);
+ page.on("pageerror", (error) => report.errors.push(error.message));
+ try {
+ await page.goto(base);
+ await page.evaluate(async () => {
+ await navigator.serviceWorker.register("./sw.js");
+ await navigator.serviceWorker.ready;
+ });
+ await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller));
+ // Model an existing installed app: its document navigation, not just
+ // a subsequent claim(), must already have gone through the old worker.
+ await page.reload();
+ await page.waitForFunction(() => navigator.serviceWorker.controller?.state === "activated");
+ await page.evaluate(() => {
+ window.results = [];
+ window.workerDebug = [];
+ window.originalController = navigator.serviceWorker.controller;
+ window.solver = new Worker("./solver-worker.js", { type: "module" });
+ window.solver.onmessage = ({ data }) => {
+ if (data.loading) window.loading = true;
+ else if (data.debug) window.workerDebug.push(data.debug);
+ else window.results.push(data);
+ };
+ window.solver.postMessage("probe");
+ });
+ await page.waitForFunction(() => window.results.length === 1);
+ assert.deepEqual(await page.evaluate(() => window.results[0]), { status: 200, body: `verified solver ${first}` });
+ assert.equal(requests.filter((request) => request.path === `solver.${first}.zip`).length, 1, "the pre-update worker uses the verified cache, not the origin");
+ await page.evaluate(() => { window.results = []; window.workerDebug = []; window.solver.postMessage("start"); });
+ // Pause in the worker itself: an outstanding fetch through the old
+ // service worker would intentionally delay activation until it finishes.
+ await page.waitForFunction(() => window.loading === true);
+ const other = await context.newPage();
+ await other.goto(base);
+ build = second;
+ await other.evaluate(async () => (await navigator.serviceWorker.getRegistration()).update());
+ await other.waitForFunction(async () => Boolean((await navigator.serviceWorker.getRegistration()).waiting));
+ await other.evaluate(async () => (await navigator.serviceWorker.getRegistration()).waiting.postMessage({ type: "ACTIVATE" }));
+ await page.waitForFunction(() => navigator.serviceWorker.controller && navigator.serviceWorker.controller !== window.originalController);
+ // Resume as soon as control changes to cover requests racing activation.
+ await page.evaluate(() => window.solver.postMessage("release"));
+ await page.waitForFunction(() => window.results.length === 1);
+ // controllerchange precedes the activate event's waitUntil work. Read
+ // diagnostic metadata only once that migration has actually completed.
+ await page.waitForFunction(() => navigator.serviceWorker.controller?.state === "activated");
+ report.retained = await page.evaluate(async () => {
+ const key = (await caches.keys()).find((key) => key.endsWith("meta:222222222222"));
+ const cache = await caches.open(key);
+ return (await cache.match(new URL(".retained-solvers.json", location.href))).json();
+ });
+ assert.deepEqual(await page.evaluate(() => window.results[0]), { status: 200, body: `verified solver ${first}` });
+ // Prove the same old URL remains available without any network fallback.
+ await stopServer();
+ await assert.rejects(fetch(base, { signal: AbortSignal.timeout(2000) }), "the origin is actually unreachable");
+ await page.evaluate(() => window.solver.postMessage("again"));
+ await page.waitForFunction(() => window.results.length === 2);
+ assert.deepEqual(await page.evaluate(() => window.results[1]), { status: 200, body: `verified solver ${first}` });
+ assert.deepEqual(report.errors, []);
+ report.ok = true;
+ report.checks = ["another tab activates while an old solver initializes", "old worker fetches its exact verified archive online and offline"];
+ } catch (error) {
+ report.ok = false;
+ report.failure = error.stack;
+ report.workerDebug = await page.evaluate(() => window.workerDebug).catch(() => null);
+ console.error(name, error);
+ console.error(JSON.stringify({ retained: report.retained, workerDebug: report.workerDebug, requests }));
+ } finally {
+ await browser.close();
+ await stopServer();
+ }
+ }
+ if (reports.some((report) => !report.ok)) process.exitCode = 1;
+})().catch((error) => { console.error(error); process.exitCode = 1; }).finally(() => {
+ fs.mkdirSync("browser-artifacts", { recursive: true });
+ fs.writeFileSync("browser-artifacts/solver-update-regressions.json", JSON.stringify(reports, null, 2));
+ server.close();
+});
diff --git a/tests/test_str8ts.py b/tests/test_str8ts.py
new file mode 100644
index 00000000..20275df0
--- /dev/null
+++ b/tests/test_str8ts.py
@@ -0,0 +1,66 @@
+from gridsolver.grid_classes.str8ts import Str8ts
+from gridsolver.solver.solver import solve
+
+
+def newspaper_grid():
+ board = [
+ [9, None, "#", 6, None, None, None, "#", "#"],
+ [None, None, None, 1, 4, None, 6, 5, None],
+ ["#", "#", None, 2, "#", "#", None, None, None],
+ [3, None, None, "#", 9, None, None, None, 5],
+ [None, None, "#", 8, None, None, "#", None, None],
+ ["#", None, None, None, None, 1, None, None, None],
+ [None, 7, None, 9, "#", None, 3, 2, "#"],
+ [None, None, None, 4, None, 5, None, None, 8],
+ [7, "#", None, None, None, "#", "#", None, None],
+ ]
+ black = {
+ (0, 2), (0, 3), (0, 7), (0, 8),
+ (2, 0), (2, 1), (2, 4), (2, 5),
+ (3, 3), (3, 8), (4, 2), (4, 6),
+ (5, 0), (5, 5), (6, 3), (6, 4), (6, 7), (6, 8),
+ (8, 0), (8, 1), (8, 5), (8, 6),
+ }
+ return Str8ts.from_board(board, black=black)
+
+
+def test_newspaper_str8ts_is_unique():
+ grid = newspaper_grid()
+ solutions = solve(grid, processes=0, max_sols=2, log_level=-1)
+ assert len(solutions) == 1
+ values = grid.values_by_key(next(iter(solutions)))
+ expected = [
+ [9, 8, None, 6, 5, 3, 4, None, None],
+ [8, 9, 3, 1, 4, 2, 6, 5, 7],
+ [None, None, 1, 2, None, None, 5, 7, 6],
+ [3, 4, 2, None, 9, 8, 7, 6, 5],
+ [4, 5, None, 8, 7, 9, None, 3, 2],
+ [None, 6, 5, 7, 8, 1, 2, 4, 3],
+ [5, 7, 6, 9, None, 4, 3, 2, None],
+ [6, 3, 7, 4, 2, 5, 1, 9, 8],
+ [7, None, 4, 5, 3, None, None, 8, 9],
+ ]
+ for row in range(9):
+ for col in range(9):
+ if expected[row][col] is not None:
+ assert values[(row, col)] == expected[row][col]
+
+
+def test_numbered_black_cells_break_streets_but_count_for_uniqueness():
+ grid = Str8ts.from_board(
+ [[1, 2, 3], [2, 3, 1], [3, 1, None]],
+ black={(1, 1)},
+ )
+ solutions = solve(grid, processes=0, max_sols=2, log_level=-1)
+ assert len(solutions) == 1
+ assert grid.values_by_key(next(iter(solutions)))[(2, 2)] == 2
+ assert (1, 1) in grid.numbered_black
+
+
+def test_malformed_numbered_black_is_rejected():
+ try:
+ Str8ts(3, black={(1, 1)}, numbered_black={(0, 0)})
+ except ValueError as exc:
+ assert "also be black" in str(exc)
+ else:
+ raise AssertionError("expected invalid numbered-black cell")
diff --git a/tests/test_web_api.py b/tests/test_web_api.py
new file mode 100644
index 00000000..3cbaf3ed
--- /dev/null
+++ b/tests/test_web_api.py
@@ -0,0 +1,112 @@
+"""Native tests for the same adapter shipped inside the browser archive."""
+import json
+import pytest
+from gridsolver.web_api import build_grid, solve_payload, solve_json
+
+
+def puzzle(kind='sudoku', rows=4, cols=None, cells=None, **extra):
+ cols = rows if cols is None else cols
+ return dict(version=1, type=kind, rows=rows, cols=cols,
+ boxRows=2, boxCols=2,
+ cells=[None] * (rows * cols) if cells is None else cells, **extra)
+
+
+SQUARE = [1,2,3,4, 3,4,1,2, 2,1,4,3, 4,3,2,1]
+
+
+def test_row_major_and_unchanged_input():
+ p = puzzle(cells=SQUARE.copy())
+ p['cells'][6] = None
+ before = json.dumps(p)
+ result = solve_payload(p)
+ assert result['status'] == 'unique'
+ assert result['solutions'][0]['cells'] == SQUARE
+ assert json.dumps(p) == before
+
+
+def test_multiple_and_no_solution():
+ assert solve_payload(puzzle())['status'] == 'multiple'
+ p = puzzle(cells=SQUARE.copy())
+ p['cells'][1] = 1
+ assert solve_payload(p)['status'] == 'no-solution'
+
+
+@pytest.mark.parametrize('kind', ['latinsquare', 'futoshiki', 'killersudoku', 'kenken'])
+def test_dense_families(kind):
+ extra = {}
+ if kind in ('killersudoku', 'kenken'):
+ extra['cages'] = [{'cells': [i], 'target': n, 'op': '+'} for i, n in enumerate(SQUARE)]
+ if kind == 'futoshiki':
+ extra['inequalities'] = [{'less': 0, 'greater': 1}]
+ p = puzzle(kind, cells=SQUARE.copy(), **extra)
+ p['cells'][0] = None
+ assert solve_payload(p)['solutions'][0]['cells'] == SQUARE
+
+
+def test_path_blocked_and_rectangular():
+ p = puzzle('hidato', 2, 3, [1, '#', 5, 2, None, 4])
+ result = solve_payload(p)
+ assert result['status'] == 'unique'
+ assert result['solutions'][0]['cells'] == [1, '#', 5, 2, 3, 4]
+ p = puzzle('numbrix', 2, 3, [1, 2, 3, 6, None, 4])
+ assert solve_payload(p)['solutions'][0]['cells'] == [1, 2, 3, 6, 5, 4]
+
+
+def test_slitherlink_zero_and_edge_encoding():
+ assert solve_payload(puzzle('slitherlink', 1, cells=[0]))['status'] == 'no-solution'
+ result = solve_payload(puzzle('slitherlink', 1, cells=[4]))
+ assert result['status'] == 'unique'
+ assert result['solutions'][0]['edges'] == [['H', 0, 0], ['H', 1, 0], ['V', 0, 0], ['V', 0, 1]]
+
+
+def test_kakuro():
+ p = puzzle('kakuro', 3, cells=['#','#','#', '#',1,None, '#',None,None], clues=[
+ {'cell': 1, 'down': 4}, {'cell': 2, 'down': 6},
+ {'cell': 3, 'across': 3}, {'cell': 6, 'across': 7}])
+ result = solve_payload(p)
+ assert result['status'] == 'unique'
+ assert result['solutions'][0]['cells'] == ['#','#','#', '#',1,2, '#',3,4]
+
+
+@pytest.mark.parametrize('bad', [True, -1, 0, 26, 3.5, '4', None])
+def test_bad_dimensions(bad):
+ with pytest.raises(ValueError):
+ build_grid(puzzle(rows=4) | {'rows': bad})
+
+
+@pytest.mark.parametrize('change', [
+ {'type': 'auto'}, {'cells': [True] * 16}, {'cells': [0] * 16},
+ {'rows': 3}, {'version': 2}, {'diagonal': True},
+ {'inequalities': [{'less': 0, 'greater': 1}]},
+ {'cells': ['#'] * 16}, {'boxRows': 3},
+ {'cages': [{'cells': [0], 'target': 1}]},
+])
+def test_reject_silently_ignored_or_malformed_data(change):
+ with pytest.raises(ValueError):
+ build_grid(puzzle() | change)
+
+
+def test_missing_and_overlapping_cages():
+ for cages in ([], [{'cells': [0], 'target': 1}],
+ [{'cells': [0,0], 'target': 3}]):
+ with pytest.raises(ValueError):
+ build_grid(puzzle('killersudoku', cages=cages))
+
+
+def test_invalid_json_and_executable_text():
+ assert json.loads(solve_json('import os'))['status'] == 'invalid'
+ assert json.loads(solve_json('[]'))['status'] == 'invalid'
+ assert json.loads(solve_json('x' * 200001))['status'] == 'invalid'
+
+
+def test_browser_str8ts_numbered_black_cell():
+ from gridsolver.web_api import solve_payload
+ p = {
+ "version": 1, "type": "str8ts", "rows": 3, "cols": 3,
+ "black": [4],
+ "cells": [1, 2, 3, 2, 3, 1, 3, 1, None],
+ "cages": [], "inequalities": [], "clues": [],
+ }
+ result = solve_payload(p)
+ assert result["status"] == "unique"
+ assert result["solutions"][0]["cells"] == [1,2,3,2,3,1,3,1,2]
diff --git a/tests/test_web_review3.py b/tests/test_web_review3.py
new file mode 100644
index 00000000..19f23ceb
--- /dev/null
+++ b/tests/test_web_review3.py
@@ -0,0 +1,74 @@
+import json
+from pathlib import Path
+import pytest
+from gridsolver.web_api import build_grid
+from scripts.build_web import build_destination, validate_output
+
+FIXTURES = json.loads(
+ (Path(__file__).parents[1] / "web/tests/fixtures/payloads.json").read_text()
+)
+
+
+@pytest.mark.parametrize("fixture", FIXTURES, ids=lambda f: f["name"])
+def test_shared_payload_contract(fixture):
+ if fixture["solver"]:
+ build_grid(fixture["payload"])
+ else:
+ with pytest.raises(ValueError):
+ build_grid(fixture["payload"])
+
+
+@pytest.mark.parametrize(
+ "name",
+ ["web", "gridsolver", ".git", "tests", "scripts", ".", "..", "web/generated"],
+)
+def test_builder_refuses_source_paths_without_deleting(name, tmp_path):
+ root = tmp_path / "repo"
+ root.mkdir()
+ (root / "web").mkdir()
+ sentinel = root / "web/source.js"
+ sentinel.write_text("keep")
+ with pytest.raises(ValueError), build_destination(root, name):
+ pytest.fail("unsafe output accepted")
+ assert sentinel.read_text() == "keep"
+
+
+def test_builder_refuses_unowned_existing_directory(tmp_path):
+ root = tmp_path / "repo"
+ root.mkdir()
+ out = root / "_site"
+ out.mkdir()
+ (out / "sentinel").write_text("keep")
+ with pytest.raises(ValueError), build_destination(root, "_site"):
+ pass
+ assert (out / "sentinel").read_text() == "keep"
+
+
+def test_failed_build_preserves_previous_output_and_success_replaces_it(tmp_path):
+ root = tmp_path / "repo"
+ root.mkdir()
+ with build_destination(root, "_site") as out:
+ (out / "index.html").write_text("old")
+ with pytest.raises(RuntimeError), build_destination(root, "_site") as out:
+ (out / "index.html").write_text("partial")
+ raise RuntimeError("build failed")
+ assert (root / "_site/index.html").read_text() == "old"
+ with build_destination(root, "_site") as out:
+ (out / "index.html").write_text("new")
+ assert (root / "_site/index.html").read_text() == "new"
+ assert not list(root.glob("._site-stage-*"))
+ assert not list(root.glob("._site-backup-*"))
+
+
+def test_builder_rejects_symbolic_output(tmp_path):
+ root = tmp_path / "repo"
+ root.mkdir()
+ target = tmp_path / "target"
+ target.mkdir()
+ try:
+ (root / "_site").symlink_to(target, target_is_directory=True)
+ except OSError:
+ pytest.skip("symlink creation is unavailable")
+ with pytest.raises(ValueError):
+ validate_output(root, "_site")
+ assert target.is_dir()
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 00000000..6b71cb0f
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,99 @@
+# GridPuzzle phone scanner
+
+The `browser-scanner` branch provides an installable, camera-first static web app at:
+
+https://senegrom.github.io/GridPuzzle/
+
+Recognition and the complete Python GridPuzzle solver run on-device. Photographs are not uploaded to a recognition service or remote solver. Nothing in the deployment workflow merges this branch into `master`.
+
+## Build and deploy
+
+Requirements: Python 3.14+, Node.js 22+, and network access while building the pinned browser packages.
+
+```sh
+python -m pip install -e '.[dev]'
+python -m pytest -q tests -m 'not slow'
+node --test web/tests/*.test.js
+python scripts/build_web.py
+python -m http.server 8000 --directory _site
+```
+
+Camera permissions require localhost or HTTPS. The existing `Build and deploy phone scanner` workflow is the single expensive deployment gate: it builds `_site`, runs Python and browser unit tests, executes the real Chromium/mobile-WebKit Python and OCR acceptance suites (including the real-newspaper fixtures), uploads that tested artifact, and deploys it through `gridpuzzle-browser-pages`.
+
+The app is a multi-file static site, not a Python server. Runtime Python, OCR, English training data and icons are self-hosted. The build verifies every npm tarball against a pinned SHA-512 integrity value before unpacking it and ships only the LSTM Tesseract cores the bundled model uses. `build-info.json` records the exact source commit and package integrity metadata; `assets.json` records SHA-256 digests.
+
+## Features
+
+- Rear-facing live camera with manual shutter and optional stable-grid capture.
+- Photo-library import and a native camera-file fallback for denied/unavailable live camera access.
+- Four draggable crop corners, rotation, projective straightening, automatic continuous-grid size detection, explicit dimensions and puzzle-type selection.
+- Local printed-clue OCR with confidence/review flags and guided **Review highlighted clues → Save & next**.
+- All twelve solver families: Sudoku, Killer Sudoku, Futoshiki, KenKen, Latin square, diagonal Latin square, pandiagonal Latin square, Hidato, Numbrix, Kakuro, Slitherlink and Str8ts.
+- Str8ts support includes solid black street separators and numbered black cells. Numbered black cells constrain row/column uniqueness but do not join a street.
+- Editors for values/blocked or black cells, cages, inequalities and Kakuro directional clues, plus undo and validated JSON import/export.
+- A strict Python data boundary and the full Python 3.14 solver through Pyodide in a cancellable worker. Browser solving uses sequential search capped at two solutions to distinguish no/unique/multiple solutions without unsupported browser multiprocessing.
+- Clean-board and captured-photo overlays, including Slitherlink edges, plus PNG overlay export.
+- Local puzzle/settings persistence. Recognition uncertainty is persisted atomically; photographs and solver results are not.
+- Installable PWA icons, hash-verified offline preparation and a request for persistent browser storage. A banner at the top of the page announces a ready update; nothing reloads until the user chooses to.
+
+## Recognition trust model
+
+Automatic recognition is a proposal, not proof. A faint or cropped clue can look like an intentionally blank cell, so an **automatically identified puzzle type always requires one rules confirmation**, including boxed Sudoku. Structural families such as Str8ts remain review-gated. An explicitly selected type represents a separate user decision, but uncertainty flags still block silent trust of suspect readings.
+
+A unique solution verifies only the transcribed rules and clues. It does not prove the photograph was read correctly.
+
+Newsprint handling now uses solid-cell statistics to distinguish true black separators from gray Sudoku shading, connected-component cleanup to suppress paper/halftone specks, and local per-digit Otsu binarization before the bounded Tesseract atlas call. Every single-glyph digit is then re-read on its own, once from its binary crop and once from its grayscale crop, and the three readings vote: unanimity clears the review flag even at low individual scores, any disagreement keeps it. The two user-provided newspaper crops are retained under `Examples/BrowserScanner/Newspaper/` and are not shipped in the PWA bundle.
+
+On the 2026-09-07 real newspaper regressions, Chromium 153 and WebKit 26.6 both read the shaded Sudoku **24/24**. They both read the Str8ts **19/20** printed values, with the one missed clue explicitly flagged for review; both detect the Str8ts black-cell layout exactly and produce **zero unsafe unflagged discrepancies**. Generated regressions remain useful secondary baselines: Chromium reads all tested generated variants exactly, while the current WebKit perspective/shadow case reads 29/30 with the miss flagged.
+
+Cage boundaries/targets, inequalities, Kakuro directions and path-puzzle identification remain experimental and require review. Handwriting, alphabetic large-grid clues, arbitrary publisher layouts and invisible variant rules are not promised. Physical-iPhone autofocus/exposure, installed-mode camera behaviour, storage eviction and airplane-mode use still require hardware testing.
+
+Digit cleanup retains neighbouring glyphs in multi-digit numbers and preserves ink in pure black-and-white scans. Invalid Str8ts values and Kakuro targets become highlighted blanks for correction. An incompatible cage reading leaves its cells uncovered; missing or ambiguous targets remain unset. These incomplete structures are editable, and Solve requires their correction rather than accepting an invented operator or target.
+
+## Data contract
+
+```json
+{
+ "version": 1,
+ "type": "str8ts",
+ "rows": 4,
+ "cols": 4,
+ "cells": [1, null, "#", 4, 2, 3, 4, 1, 3, 4, 1, 2, 4, 1, 2, 3],
+ "black": [2],
+ "cages": [],
+ "inequalities": [],
+ "clues": []
+}
+```
+
+Cells are zero-based row-major. `null` is blank; `"#"` is a blocked/blank-black cell where the family supports it; Slitherlink `0` is a real face clue. Str8ts uses `black` as a distinct list of black-cell indices; an index in `black` may contain either `"#"` or an integer printed on that black cell. Cages use `{ "cells": [0,1], "target": 3, "op": "+" }`; inequalities use `{ "less": 0, "greater": 1 }`; a Kakuro clue on a blocked cell can use `{ "cell": 0, "across": 16, "down": 23 }`.
+
+The browser rejects malformed dimensions, boxes, Str8ts black metadata, overlapping/disconnected cages, invalid cage arity/operators, nonadjacent inequalities and empty Kakuro clue objects before they can become solve requests. Incomplete cage coverage and missing OCR targets remain editable states, but **Solve** performs a solve-ready check before Pyodide starts. The Python adapter remains the authoritative final boundary.
+
+The 25×25 browser limit is a phone resource policy, not a native solver limit. Str8ts itself is limited to square 2×2 through 9×9 boards. A deadline/cancellation means unfinished, never unsatisfiable or unique.
+
+## Offline behaviour
+
+Offline requests are scoped to `/GridPuzzle/`. Root navigation maps to cached `index.html` even when a bookmark/share URL includes query parameters. Runtime assets live in one content-addressed cache keyed by SHA-256, and each build's asset list is stored separately, so an update reuses unchanged verified bytes instead of downloading the whole Pyodide/Tesseract bundle again. Every downloaded asset is digest-verified before it is stored.
+
+The startup status is a cheap presence check. **Download for offline use** performs full sequential digest verification, evicts/refetches anything that fails, and asks the browser for persistent storage. Ordinary requests trust bytes already verified before write, so large WASM files are not re-hashed on every fetch. If the browser evicts the asset list, in-scope requests fall back to the network and restore it online. Cache quota failure does not break a verified online response.
+
+## Testing
+
+- `tests/test_web_api.py` verifies the Python/browser data contract; `tests/test_str8ts.py` verifies Str8ts street semantics and the uniquely solved newspaper puzzle.
+- `web/tests/` covers geometry, classification, OCR mapping/preprocessing, cache recovery, worker lifecycle, malformed input, type confirmation, structural validation, keyboard boundaries, no-op edit guards and service-worker routing.
+- `scripts/browser_smoke.cjs` exercises all twelve puzzle families through the real Python/Pyodide solver in Chromium and WebKit.
+- `scripts/browser_regressions.cjs` exercises generated OCR/perspective/review regressions.
+- `scripts/newspaper_regressions.cjs` runs the production scanner/Tesseract pipeline against the two real user-provided newspaper crops in Chromium and WebKit. Any wrong, missed or invented clue that is not review-flagged fails the deployment.
+- The newspaper images and hand-checked ground truth live in `Examples/BrowserScanner/Newspaper/`, outside `web/`, so they do not inflate the deployed/offline bundle.
+- Normal Linux/Windows CI and forward compatibility remain independent from the single full Pages/browser gate.
+
+Generated fixtures are regression baselines, not substitutes for real-device testing.
+
+## Input, build and lifecycle hardening
+
+The page declares a same-origin Content Security Policy and a no-referrer policy; every runtime asset is self-hosted. Builds are staged before publication. Inside the repository, only `_site` is accepted as output; custom external outputs must be new or builder-owned. Source directories, Git metadata, repository ancestors and symbolic output links are refused, and a failed build preserves the previous good output.
+
+Task/deadline ownership, edit snapshots, camera/photo flow and offline controls are separate modules. Grayscale/threshold/region preparation runs off the UI thread. Each OCR scan owns a dedicated host that can terminate raw Tesseract workers even while language initialization is pending. Stale task generations cannot replace a newer puzzle.
+
+Live moving-camera AR and step-by-step deduction explanations are not included in this branch.
diff --git a/web/TESTING.md b/web/TESTING.md
new file mode 100644
index 00000000..951d3f13
--- /dev/null
+++ b/web/TESTING.md
@@ -0,0 +1,46 @@
+# Browser acceptance and recognition measurements
+
+The deployment build tests the actual Python solver and OCR WebAssembly in both Chromium and mobile WebKit; it does not substitute a JavaScript solver or mocked OCR. Browser versions and raw scan measurements are recorded in the uploaded report artifact.
+
+## Recognition is measured before correction
+
+Every single-glyph digit is read three times: in the sparse-text atlas, and on its own as a single character from its binary crop and from its grayscale crop. The readings vote; unanimity of at least two readers clears the review flag, any disagreement or a lone reading keeps it. Across eight fonts in Chromium and WebKit this raised clean correct readings from 364 to 453 of 480 digits and removed the only unflagged misread.
+
+Generated acceptance fixtures record raw cells, confidence/review flags and discrepancies **before** manual correction. A wrong or missed clue without a review flag fails. Generated fixtures are baselines, not claims about arbitrary photographs, handwriting or publisher styles.
+
+The generated browser suite also requires exact transcription of a binary 4×4 Sudoku and a Numbrix grid with multi-digit clues. Unit regressions verify complete glyph grouping with speckle removal, threshold-boundary pixels in both polarities, and correction of invalid Str8ts/Kakuro readings and incompatible KenKen cages without weakening import or solve validation.
+
+The deployment also runs two real user-provided newspaper crops from `Examples/BrowserScanner/Newspaper/` through the same production scanner and self-hosted Tesseract.js path. `newspaper-regressions.json` compares the raw transcription with hand-checked `ground-truth.json` and fails on any unsafe unflagged discrepancy or incorrect Str8ts black-cell geometry.
+
+For the 2026-09-07 fixtures, Chromium 153.0.8010.12 and WebKit 26.6 both produce:
+
+- shaded Sudoku: **24/24** printed values correct, no structural black cells, no unsafe discrepancies;
+- Str8ts: **19/20** printed values correct, exact 22-cell black layout, with the one missed white-cell clue flagged for review and no unsafe discrepancies.
+
+The same run's generated suite reads all baseline, serif, shifted and 4×4 values exactly in both browsers. Chromium also reads the perspective/shadow case 30/30; WebKit reads it 29/30 and flags the miss. Production code never substitutes fixture answers.
+
+Automatic classification is treated as a trust boundary: automatically identified puzzles remain `needsReview` until their rules are confirmed. Str8ts black cells are structural data and remain review-gated even when OCR is otherwise clean.
+
+## Offline test method
+
+The preview is served under `/GridPuzzle/`, matching Pages. After hash-verified offline preparation, the test stops the HTTP server and verifies that the origin is unreachable. The page then reloads, starts a fresh Python worker, solves, imports a photo and performs fresh OCR while the origin remains unavailable.
+
+Unit tests verify that `/GridPuzzle/?query=...` navigation maps to cached `index.html`, while real subpaths are not silently rewritten. Explicit offline preparation re-hashes the complete asset set; startup status is only a presence check. Assets are content-addressed, so updates reuse unchanged verified bytes.
+
+This is not a physical-iPhone airplane-mode, autofocus, installed-camera or storage-eviction test. Those require hardware.
+
+## Other assertions
+
+Coverage includes all twelve solver families, phone layouts, malformed imports, Str8ts black metadata, early cage/Kakuro validation, solve-ready checks, clue editing, stale-result invalidation, undo, no-op removal guards, bounded keyboard navigation, type changes preserving clues, cancellation/restart, pagehide cleanup, persistent scan uncertainty, denied-camera fallback, photo-overlay invalidation, cache recovery and absence of external runtime requests.
+
+Editor regressions check independent numeric/cage warnings through both edit forms, Undo and reload; JSON drafts through view changes and asynchronous solver results; accessible labels for solved cells and Kakuro targets; and superseded JSON import errors. Unit tests also exercise delayed photo decode failures after cancellation or replacement, while preserving errors from the active import.
+
+The browser suite also requires exact transcription of transparent PNGs through both image decode paths, and verifies detected/manual scan dimensions through Board refreshes, editing-tool changes and Undo. Controlled service-worker tests cover activation in another tab, a click racing activation, and initial installation without an unnecessary reload.
+
+Layout/editor coverage includes changing scan families while retaining an existing board, correcting and applying Sudoku box dimensions, and keeping the controls available in automatic mode and after Undo. Keyboard regressions select, extend, deselect and save both cages and inequalities with Enter, Space and arrow keys, checking focus after each board redraw.
+
+Camera lifecycle tests cover cancellation while permission, video playback or grid detection is pending. Chromium and WebKit tests also exercise the application's cancellation wiring with controlled media, verifying that editing and solving stop capture and ignore queued detection callbacks.
+
+Both browsers exercise a real two-tab service-worker update while an old dedicated solver worker is initializing, then request its original verified archive online and with the origin server stopped. This focused lifecycle fixture controls the initialization delay; the full solver and OCR checks above still use the production runtimes. Unit tests cover changed and reused archive bytes, repeated updates, workers that are not enumerable yet, and cleanup after the owning clients close. Old solver archives are retained for the tabs and workers present at activation and pruned at a later activation once those clients have gone.
+
+The `Build and deploy phone scanner` workflow is the single full Chromium/WebKit deployment gate. Lightweight PR browser CI runs unit/parse checks; normal Linux/Windows CI and forward compatibility remain independent.
diff --git a/web/app.js b/web/app.js
new file mode 100644
index 00000000..57381e4b
--- /dev/null
+++ b/web/app.js
@@ -0,0 +1,1154 @@
+import { nextReviewCell } from "./model.js";
+import { createTaskController } from "./task-controller.js";
+import { captureEdit, restoreEdit, rememberEdit } from "./edit-history.js";
+import { setupPhotoFlow } from "./photo-flow.js";
+import { setupOffline } from "./offline.js";
+import {
+ TYPES,
+ makePuzzle,
+ demo,
+ clone,
+ checkShape,
+ conflicts,
+ isCage,
+ boxShape,
+ moveIndex,
+ hasCageRemoval,
+ hasInequalityRemoval,
+ checkSolveReady,
+} from "./model.js";
+import { Scanner } from "./scanner.js";
+import { homography, project } from "./geometry.js";
+import { saveSession, restoreSession } from "./session.js";
+
+const $ = (id) => document.getElementById(id),
+ NS = "http://www.w3.org/2000/svg",
+ scanner = new Scanner();
+const state = {
+ puzzle: makePuzzle(),
+ layout: null,
+ uncertain: new Set(),
+ cageUncertain: new Set(),
+ needsReview: false,
+ notes: [],
+ result: null,
+ solution: 0,
+ photo: null,
+ rectified: null,
+ puzzleSource: null,
+ photoSource: null,
+ corners: null,
+ photoRows: 0,
+ photoCols: 0,
+ view: "board",
+ selected: [],
+ history: [],
+};
+let worker = null,
+ editing = 0,
+ focused = 0,
+ renderedJson = "";
+const storage = {
+ get: (key) => {
+ try {
+ return JSON.parse(localStorage.getItem(key));
+ } catch {
+ return null;
+ }
+ },
+ set: (key, value) => {
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ } catch {
+ /* Private/storage-full mode must not break solving. */
+ }
+ },
+};
+for (const [value, label] of Object.entries(TYPES)) {
+ const option = document.createElement("option");
+ option.value = value;
+ option.textContent = label;
+ $("puzzle-type").append(option);
+}
+const applyType = document.createElement("button");
+applyType.id = "use-type";
+applyType.className = "text-button";
+applyType.hidden = true;
+$("type-help").after(applyType);
+// Version 1 preferences could hold a puzzle type written by board loading
+// rather than chosen by the user, so only the explicit settings migrate and
+// the scan type starts over at automatic detection.
+const legacyPrefs = storage.get("gridpuzzle-settings-v1"),
+ prefs =
+ storage.get("gridpuzzle-settings-v2") ||
+ (legacyPrefs && typeof legacyPrefs === "object"
+ ? { ...legacyPrefs, type: "auto" }
+ : null);
+if (prefs) {
+ if (prefs.type === "auto" || Object.hasOwn(TYPES, prefs.type))
+ $("puzzle-type").value = prefs.type;
+ for (const id of ["auto-capture", "auto-solve"])
+ if (typeof prefs[id] === "boolean") $(id).checked = prefs[id];
+ if (["0", "30", "90", "300"].includes(prefs.limit))
+ $("time-limit").value = prefs.limit;
+}
+function savePrefs() {
+ storage.set("gridpuzzle-settings-v2", {
+ type: $("puzzle-type").value,
+ "auto-capture": $("auto-capture").checked,
+ "auto-solve": $("auto-solve").checked,
+ limit: $("time-limit").value,
+ });
+}
+for (const id of ["puzzle-type", "auto-capture", "auto-solve", "time-limit"])
+ $(id).addEventListener("change", savePrefs);
+const layoutFields = {
+ rows: "rows", cols: "cols", boxRows: "box-rows", boxCols: "box-cols",
+};
+function setLayout(layout) {
+ state.layout = Object.fromEntries(
+ Object.entries(layoutFields).map(([key, id]) => {
+ $(id).value = layout[key];
+ return [key, layout[key]];
+ }),
+ );
+}
+for (const [key, id] of Object.entries(layoutFields))
+ $(id).addEventListener("input", () => {
+ // Keep incomplete/invalid input editable until Read or Apply validates it.
+ state.layout[key] = $(id).value;
+ });
+function typeControl() {
+ const type = $("puzzle-type").value;
+ applyType.hidden = type === "auto" || type === state.puzzle.type;
+ applyType.textContent = `Use ${TYPES[type] || "this type"} for the current board`;
+ // These controls configure the next scan/layout. Automatic detection may
+ // propose Sudoku even when the current board belongs to another family.
+ $("box-fields").hidden = !["auto", "sudoku", "killersudoku"].includes(type);
+}
+function reviewCells() {
+ return new Set([...state.uncertain, ...state.cageUncertain]);
+}
+$("puzzle-type").addEventListener("change", typeControl);
+function status(text, detail = "", kind = "info", progress = null) {
+ delete $("status").dataset.result;
+ $("status").className = `status ${kind}`;
+ $("status-text").textContent = text;
+ $("status-detail").textContent = detail;
+ $("progress").hidden = !tasks.busy;
+ if (progress === null) $("progress").removeAttribute("value");
+ else $("progress").value = progress;
+}
+function fail(error) {
+ if (error?.name !== "AbortError")
+ status(
+ error?.message || String(error),
+ "Nothing was uploaded or sent to a remote solver.",
+ "error",
+ );
+}
+function remember() {
+ rememberEdit(state);
+}
+
+function persist() {
+ saveSession(storage, state);
+}
+const tasks = createTaskController({
+ $,
+ scanner,
+ status,
+ onStop: (wasBusy) => {
+ stopCamera();
+ if (wasBusy && worker) {
+ worker.terminate();
+ worker = null;
+ }
+ },
+});
+const stopTask = (message) => tasks.stop(message);
+const begin = () => tasks.begin();
+const finish = () => tasks.finish();
+function invalidate() {
+ stopTask();
+ state.result = null;
+ state.solution = 0;
+ state.view = "board";
+ status(
+ "Puzzle changed.",
+ "Solve again to check the updated clues and rules.",
+ );
+}
+function mutate(fn) {
+ const previous = captureEdit(state),
+ history = [...state.history];
+ remember();
+ invalidate();
+ try {
+ fn();
+ checkShape(state.puzzle);
+ } catch (error) {
+ restoreEdit(state, previous);
+ state.history = history;
+ render();
+ throw error;
+ }
+ persist();
+ render();
+}
+function normalized(p) {
+ checkShape(p);
+ return {
+ ...clone(p),
+ boxRows: p.boxRows === undefined ? 3 : p.boxRows,
+ boxCols: p.boxCols === undefined ? 3 : p.boxCols,
+ cages: clone(p.cages || []),
+ inequalities: clone(p.inequalities || []),
+ clues: clone(p.clues || []),
+ black: clone(p.black || []),
+ };
+}
+export function loadPuzzle(payload) {
+ const p = normalized(payload);
+ remember();
+ invalidate();
+ stopCamera();
+ state.puzzle = p;
+ setLayout(p);
+ state.uncertain.clear();
+ state.cageUncertain.clear();
+ state.needsReview = false;
+ state.notes = [];
+ state.photo =
+ state.rectified =
+ state.puzzleSource =
+ state.photoSource =
+ state.corners =
+ null;
+ $("photo-panel").hidden = true;
+ state.selected = [];
+ focused = 0;
+ persist();
+ render({ replaceDraft: true });
+ status(
+ "Puzzle loaded.",
+ `${TYPES[p.type]} · Tap any cell to edit its printed clue.`,
+ );
+}
+export function getState() {
+ return {
+ puzzle: clone(state.puzzle),
+ result: clone(state.result),
+ uncertain: [...reviewCells()],
+ cellUncertain: [...state.uncertain],
+ cageUncertain: [...state.cageUncertain],
+ needsReview: state.needsReview,
+ busy: tasks.busy,
+ };
+}
+function svg(tag, attrs = {}, text = null) {
+ const node = document.createElementNS(NS, tag);
+ for (const [k, v] of Object.entries(attrs))
+ if (k === "style")
+ // CSSOM writes are allowed under the strict style-src policy; a style
+ // attribute set through setAttribute is not.
+ for (const declaration of String(v).split(";")) {
+ const at = declaration.indexOf(":");
+ if (at > 0)
+ node.style.setProperty(
+ declaration.slice(0, at).trim(),
+ declaration.slice(at + 1).trim(),
+ );
+ }
+ else node.setAttribute(k, String(v));
+ if (text !== null) node.textContent = String(text);
+ return node;
+}
+function drawBoard() {
+ const p = state.puzzle,
+ board = $("board"),
+ size = 72,
+ margin = 5,
+ sol = state.result?.solutions?.[state.solution],
+ bad = conflicts(p),
+ flagged = reviewCells();
+ focused = Math.min(focused, p.cells.length - 1);
+ board.style.minWidth = `${Math.max(240, p.cols * 34)}px`;
+ board.replaceChildren();
+ board.setAttribute(
+ "viewBox",
+ `-${margin} -${margin} ${p.cols * size + 2 * margin} ${p.rows * size + 2 * margin}`,
+ );
+ const cages = new Map();
+ p.cages.forEach((c, k) => c.cells.forEach((i) => cages.set(i, k)));
+ for (let i = 0; i < p.cells.length; i++) {
+ const r = Math.floor(i / p.cols),
+ c = i % p.cols,
+ x = c * size,
+ y = r * size,
+ given = p.cells[i],
+ value = sol?.cells[i] ?? given,
+ isBlack = given === "#" || (p.type === "str8ts" && (p.black || []).includes(i));
+ const classes = ["board-cell"];
+ if (isBlack) classes.push("blocked");
+ else if (given === null && Number.isInteger(value)) classes.push("answer");
+ if (flagged.has(i)) classes.push("uncertain");
+ if (bad.has(i)) classes.push("conflict");
+ if (state.selected.includes(i)) classes.push("selected");
+ const clue =
+ p.type === "kakuro" && given === "#"
+ ? p.clues.find((q) => q.cell === i)
+ : null;
+ const description =
+ given === "#"
+ ? [
+ "blocked",
+ clue?.across != null ? `across ${clue.across}` : "",
+ clue?.down != null ? `down ${clue.down}` : "",
+ ].filter(Boolean).join(", ")
+ : value === null
+ ? "blank"
+ : `${isBlack ? "black clue " : given === null ? "solution " : ""}${value}`;
+ const review = [
+ state.uncertain.has(i) ? "check reading" : "",
+ state.cageUncertain.has(i) ? "check cage" : "",
+ ].filter(Boolean);
+ const g = svg("g", {
+ class: classes.join(" "),
+ "data-cell": i,
+ role: "button",
+ tabindex: i === focused ? 0 : -1,
+ "aria-label": [`Row ${r + 1}, column ${c + 1}: ${description}`, ...review].join(", "),
+ });
+ g.append(
+ svg("rect", { x, y, width: size, height: size, class: "cell-hit" }),
+ );
+ if (given === "#" && p.type === "kakuro") {
+ if (clue) {
+ g.append(svg("path", { d: `M${x},${y}l72,72`, stroke: "#829b91" }));
+ if (clue.across != null)
+ g.append(
+ svg(
+ "text",
+ { x: x + 51, y: y + 24, class: "kakuro-clue" },
+ clue.across,
+ ),
+ );
+ if (clue.down != null)
+ g.append(
+ svg(
+ "text",
+ { x: x + 21, y: y + 59, class: "kakuro-clue" },
+ clue.down,
+ ),
+ );
+ }
+ } else if (Number.isInteger(value))
+ g.append(
+ svg(
+ "text",
+ {
+ x: x + 36,
+ y: y + 47,
+ style: `font-size:${value >= 100 ? 22 : 30}px`,
+ },
+ value,
+ ),
+ );
+ board.append(g);
+ }
+ if (
+ ["sudoku", "killersudoku"].includes(p.type) &&
+ Number.isInteger(p.boxRows) &&
+ Number.isInteger(p.boxCols) &&
+ p.boxRows > 0 &&
+ p.boxCols > 0
+ ) {
+ for (let r = 0; r <= p.rows; r += p.boxRows)
+ board.append(
+ svg("path", {
+ d: `M0 ${r * size}H${p.cols * size}`,
+ class: "box-line",
+ }),
+ );
+ for (let c = 0; c <= p.cols; c += p.boxCols)
+ board.append(
+ svg("path", {
+ d: `M${c * size} 0V${p.rows * size}`,
+ class: "box-line",
+ }),
+ );
+ }
+ if (isCage(p.type))
+ p.cages.forEach((cage, k) => {
+ for (const i of cage.cells) {
+ const r = Math.floor(i / p.cols),
+ c = i % p.cols,
+ x = c * size,
+ y = r * size;
+ let d = "";
+ if (r === 0 || cages.get(i - p.cols) !== k)
+ d += `M${x + 4} ${y + 4}h64`;
+ if (c === p.cols - 1 || cages.get(i + 1) !== k)
+ d += `M${x + 68} ${y + 4}v64`;
+ if (r === p.rows - 1 || cages.get(i + p.cols) !== k)
+ d += `M${x + 4} ${y + 68}h64`;
+ if (c === 0 || cages.get(i - 1) !== k) d += `M${x + 4} ${y + 4}v64`;
+ board.append(
+ svg("path", {
+ d,
+ class: "cage-line",
+ "stroke-dasharray": p.type === "killersudoku" ? "3 3" : "none",
+ }),
+ );
+ }
+ const i = Math.min(...cage.cells),
+ text = `${cage.target ?? "?"}${p.type === "kenken" ? { "*": "×", "/": "÷" }[cage.op] || cage.op || "+" : ""}`;
+ board.append(
+ svg(
+ "text",
+ {
+ x: (i % p.cols) * size + 8,
+ y: Math.floor(i / p.cols) * size + 17,
+ "font-size": 13,
+ fill: "#45665e",
+ "pointer-events": "none",
+ },
+ text,
+ ),
+ );
+ });
+ for (const q of p.inequalities) {
+ const ar = Math.floor(q.less / p.cols),
+ ac = q.less % p.cols,
+ br = Math.floor(q.greater / p.cols),
+ bc = q.greater % p.cols,
+ x = ((ac + bc + 1) * size) / 2,
+ y = ((ar + br + 1) * size) / 2;
+ board.append(
+ svg("rect", {
+ x: x - 10,
+ y: y - 13,
+ width: 20,
+ height: 26,
+ fill: "#fff",
+ "pointer-events": "none",
+ }),
+ );
+ board.append(
+ svg(
+ "text",
+ { x, y: y + 8, class: "inequality" },
+ ar === br ? (ac < bc ? "<" : ">") : ar < br ? "⌃" : "⌄",
+ ),
+ );
+ }
+ if (p.type === "slitherlink") {
+ for (const [orientation, r, c] of sol?.edges || [])
+ board.append(
+ svg("line", {
+ x1: c * size,
+ y1: r * size,
+ x2: (c + (orientation === "H" ? 1 : 0)) * size,
+ y2: (r + (orientation === "V" ? 1 : 0)) * size,
+ class: "loop-edge",
+ }),
+ );
+ for (let r = 0; r <= p.rows; r++)
+ for (let c = 0; c <= p.cols; c++)
+ board.append(
+ svg("circle", {
+ cx: c * size,
+ cy: r * size,
+ r: 3,
+ fill: "#173536",
+ "pointer-events": "none",
+ }),
+ );
+ }
+}
+function canOverlay() {
+ return !!(
+ state.photo &&
+ state.corners &&
+ state.rectified &&
+ state.puzzleSource === state.photoSource &&
+ state.result?.solutions?.length &&
+ state.photoRows === state.puzzle.rows &&
+ state.photoCols === state.puzzle.cols
+ );
+}
+function clearPhotoMapping() {
+ state.rectified = null;
+ state.photoSource = null;
+ state.photoRows = state.photoCols = 0;
+ state.view = "board";
+ $("photo-view").disabled = true;
+ $("save-photo").hidden = true;
+ $("solution-photo").hidden = true;
+ $("board-scroll").hidden = false;
+ $("clean-view").setAttribute("aria-pressed", "true");
+ $("photo-view").setAttribute("aria-pressed", "false");
+}
+function drawOverlay() {
+ if (!canOverlay()) return;
+ const out = $("solution-photo"),
+ ctx = out.getContext("2d"),
+ p = state.puzzle,
+ sol = state.result.solutions[state.solution];
+ out.width = state.photo.width;
+ out.height = state.photo.height;
+ ctx.drawImage(state.photo, 0, 0);
+ const m = homography(state.corners),
+ point = (r, c) => project(m, c / p.cols, r / p.rows);
+ ctx.strokeStyle = "#078772";
+ ctx.lineWidth = Math.max(3, out.width / 180);
+ ctx.lineCap = "round";
+ if (p.type === "slitherlink")
+ for (const [o, r, c] of sol.edges) {
+ const a = point(r, c),
+ b = point(r + (o === "V" ? 1 : 0), c + (o === "H" ? 1 : 0));
+ ctx.beginPath();
+ ctx.moveTo(a.x, a.y);
+ ctx.lineTo(b.x, b.y);
+ ctx.stroke();
+ }
+ else
+ for (let i = 0; i < p.cells.length; i++)
+ if (p.cells[i] === null && Number.isInteger(sol.cells[i])) {
+ const r = Math.floor(i / p.cols),
+ c = i % p.cols,
+ a = point(r + 0.5, c + 0.5),
+ b = point(r + 0.5, c + 1.1),
+ height = point(r + 1, c + 0.5),
+ font = Math.max(
+ 10,
+ Math.min(
+ Math.hypot(a.x - b.x, a.y - b.y),
+ Math.hypot(a.x - height.x, a.y - height.y),
+ ) * 1.05,
+ );
+ ctx.font = `650 ${font}px -apple-system,Arial,sans-serif`;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.lineWidth = Math.max(2, font * 0.1);
+ ctx.strokeStyle = "#ffffffee";
+ ctx.strokeText(String(sol.cells[i]), a.x, a.y);
+ ctx.fillStyle = "#067c6b";
+ ctx.fillText(String(sol.cells[i]), a.x, a.y);
+ }
+}
+function render({ replaceDraft = false } = {}) {
+ const p = state.puzzle;
+ $("board-meta").textContent =
+ `${TYPES[p.type]} · ${p.rows} × ${p.cols} · ${p.cells.filter(Number.isInteger).length} printed clues`;
+ // The pending photo/layout can differ from the board currently displayed.
+ setLayout(state.layout || p);
+ $("undo").disabled = !state.history.length;
+ typeControl();
+ for (const option of $("edit-tool").options)
+ option.disabled =
+ (option.value === "cage" && !isCage(p.type)) ||
+ (option.value === "inequality" && p.type !== "futoshiki");
+ if ($("edit-tool").selectedOptions[0]?.disabled)
+ $("edit-tool").value = "value";
+ $("cage-editor").hidden = $("edit-tool").value !== "cage";
+ $("inequality-editor").hidden = $("edit-tool").value !== "inequality";
+ $("cage-op").disabled = p.type === "killersudoku";
+ // Refresh pristine data, but do not discard a draft on a view change or an
+ // asynchronous solver result. Explicit puzzle loading starts a new draft.
+ if (replaceDraft || $("json-data").value === renderedJson) {
+ renderedJson = JSON.stringify(p, null, 2);
+ $("json-data").value = renderedJson;
+ }
+ drawBoard();
+ const overlay = canOverlay();
+ $("photo-view").disabled = !overlay;
+ $("save-photo").hidden = !overlay;
+ $("show-crop").hidden = !state.photo;
+ if (!overlay) state.view = "board";
+ $("board-scroll").hidden = state.view === "photo";
+ $("solution-photo").hidden = state.view !== "photo";
+ $("clean-view").setAttribute("aria-pressed", String(state.view === "board"));
+ $("photo-view").setAttribute("aria-pressed", String(state.view === "photo"));
+ if (overlay) drawOverlay();
+ $("next-solution").hidden = (state.result?.solutions?.length || 0) < 2;
+ const review = reviewCells().size || state.needsReview;
+ $("review-note").hidden = !review;
+ $("review-clues").hidden = !state.uncertain.size;
+ $("review-clues").textContent =
+ `Review ${state.uncertain.size} highlighted clues`;
+ const sourceAvailable =
+ state.rectified && state.puzzleSource === state.photoSource;
+ const checkMessage = state.uncertain.size
+ ? `${state.uncertain.size} cells need checking. ${sourceAvailable ? "Tap a highlighted cell to compare it with the photograph." : "Check the highlighted clues against the original puzzle. Photos are not retained after closing the app."}`
+ : "Confirm the puzzle type and structural clues.";
+ const cageMessage = state.cageUncertain.size
+ ? `${state.cageUncertain.size} cells need cage review. Choose Cages under Editing to check their boundaries, targets and operators.`
+ : "";
+ $("review-note").textContent = [checkMessage, cageMessage, ...state.notes].filter(Boolean).join("\n");
+ $("solve").textContent = review ? "Check & solve →" : "Solve puzzle →";
+}
+const boxDefault = boxShape;
+applyType.onclick = () => {
+ try {
+ const next = clone(state.puzzle),
+ type = $("puzzle-type").value;
+ if (!Object.hasOwn(TYPES, type))
+ throw Error("Select an explicit puzzle type.");
+ if (
+ (next.cages.length && !isCage(type)) ||
+ (next.inequalities.length && type !== "futoshiki") ||
+ (next.clues.length && type !== "kakuro") ||
+ ((next.black || []).length && type !== "str8ts")
+ )
+ throw Error(
+ "This board has structural clues for a different puzzle type. Remove those constraints explicitly or start a blank board; they will not be silently discarded.",
+ );
+ if (type === "killersudoku" && next.cages.some((c) => c.op && c.op !== "+"))
+ throw Error(
+ "Killer Sudoku cages must be sums. Correct the operators before changing the type.",
+ );
+ next.type = type;
+ checkShape(next);
+ mutate(() => {
+ state.puzzle = next;
+ if (!isCage(type)) state.cageUncertain.clear();
+ state.needsReview = Boolean(state.photo);
+ state.notes = [
+ `Rules changed to ${TYPES[type]}. Printed clues have been kept.`,
+ ];
+ state.selected = [];
+ });
+ status(
+ `Using ${TYPES[type]}.`,
+ "Printed values are unchanged. Check the rules before solving.",
+ );
+ } catch (error) {
+ fail(error);
+ }
+};
+function openCell(i) {
+ stopTask(tasks.busy ? "Stopped for editing." : null);
+ editing = i;
+ focused = i;
+ const p = state.puzzle,
+ r = Math.floor(i / p.cols),
+ c = i % p.cols;
+ $("cell-title").textContent = `Row ${r + 1} · Column ${c + 1}`;
+ $("cell-value").value = Number.isInteger(p.cells[i]) ? p.cells[i] : "";
+ $("blocked-cell").checked = p.cells[i] === "#" || (p.type === "str8ts" && (p.black || []).includes(i));
+ $("block-option").hidden = !["hidato", "kakuro", "str8ts"].includes(p.type);
+ $("cell-error").textContent = "";
+ const clue = p.clues.find((q) => q.cell === i);
+ $("across-value").value = clue?.across ?? "";
+ $("down-value").value = clue?.down ?? "";
+ blockInputs();
+ $("clue-crop").hidden = !(
+ state.rectified &&
+ state.puzzleSource === state.photoSource &&
+ state.photoRows === p.rows &&
+ state.photoCols === p.cols
+ );
+ if (!$("clue-crop").hidden) {
+ const out = $("clue-crop"),
+ ctx = out.getContext("2d"),
+ cw = state.rectified.width / p.cols,
+ ch = state.rectified.height / p.rows;
+ ctx.fillStyle = "#fff";
+ ctx.fillRect(0, 0, 180, 180);
+ ctx.drawImage(state.rectified, c * cw, r * ch, cw, ch, 0, 0, 180, 180);
+ }
+ $("save-next").hidden = !state.uncertain.size;
+ $("review-position").hidden = !state.uncertain.size;
+ $("review-position").textContent =
+ `${state.uncertain.size} readings left to check. Saving confirms only this cell.`;
+ $("cell-dialog").showModal();
+ $("cell-value").focus();
+ $("cell-value").select();
+}
+function blockInputs() {
+ $("cell-value").disabled = $("blocked-cell").checked && state.puzzle.type !== "str8ts";
+ $("kakuro-inputs").hidden =
+ state.puzzle.type !== "kakuro" || !$("blocked-cell").checked;
+}
+$("blocked-cell").onchange = blockInputs;
+function numberInput(id) {
+ const text = $(id).value.trim();
+ if (!text) return null;
+ if (!/^\d{1,12}$/.test(text))
+ throw Error("Use a whole number, or leave the field blank.");
+ return Number(text);
+}
+function saveCell(advance = false) {
+ try {
+ const next = clone(state.puzzle),
+ blocked = !$("block-option").hidden && $("blocked-cell").checked;
+ const entered = numberInput("cell-value");
+ if (next.type === "str8ts") {
+ next.black = (next.black || []).filter((i) => i !== editing);
+ if (blocked) next.black.push(editing);
+ next.black.sort((a, b) => a - b);
+ next.cells[editing] = blocked ? (entered ?? "#") : entered;
+ } else next.cells[editing] = blocked ? "#" : entered;
+ next.clues = next.clues.filter((q) => q.cell !== editing);
+ if (blocked && next.type === "kakuro") {
+ const across = numberInput("across-value"),
+ down = numberInput("down-value");
+ if (
+ (across !== null && (across < 1 || across > 45)) ||
+ (down !== null && (down < 1 || down > 45))
+ )
+ throw Error("Kakuro targets must be from 1 to 45.");
+ if (across !== null || down !== null)
+ next.clues.push({ cell: editing, across, down });
+ }
+ checkShape(next);
+ mutate(() => {
+ state.puzzle = next;
+ state.uncertain.delete(editing);
+ });
+ $("cell-dialog").close();
+ status("Clue saved.", "The previous solution has been cleared.");
+ if (advance) {
+ const next = nextReviewCell(state.uncertain, editing);
+ if (next !== null) openCell(next);
+ else
+ status(
+ "Highlighted readings checked.",
+ "Confirm the puzzle type and any structural clues, then solve.",
+ );
+ }
+ } catch (e) {
+ $("cell-error").textContent = e.message;
+ }
+}
+$("review-clues").onclick = () => {
+ const cell = nextReviewCell(state.uncertain);
+ if (cell !== null) openCell(cell);
+};
+$("save-next").onclick = () => saveCell(true);
+$("cell-form").onsubmit = (e) => {
+ e.preventDefault();
+ saveCell();
+};
+$("clear-cell").onclick = () => {
+ const keepBlack =
+ state.puzzle.type === "str8ts" && (state.puzzle.black || []).includes(editing);
+ $("cell-value").value = "";
+ $("blocked-cell").checked = keepBlack;
+ $("across-value").value = $("down-value").value = "";
+ saveCell();
+};
+$("close-cell").onclick = () => $("cell-dialog").close();
+function cellAction(i) {
+ const tool = $("edit-tool").value;
+ if (tool === "value") return openCell(i);
+ stopTask();
+ focused = i;
+ if (state.selected.includes(i))
+ state.selected = state.selected.filter((x) => x !== i);
+ else {
+ if (tool === "inequality" && state.selected.length === 2)
+ state.selected = [];
+ state.selected.push(i);
+ }
+ drawBoard();
+ // Redrawing replaces the selected SVG node. Keep keyboard navigation on
+ // that cell so arrows and Enter/Space can extend or change the selection.
+ $("board").querySelector(`[data-cell="${focused}"]`)?.focus();
+ status(
+ `${state.selected.length} cells selected.`,
+ tool === "cage"
+ ? "Enter the target and save the cage."
+ : "Select the smaller cell first, then the larger adjacent cell.",
+ );
+}
+$("board").onclick = (e) => {
+ const cell = e.target.closest("[data-cell]");
+ if (cell) cellAction(Number(cell.dataset.cell));
+};
+$("board").onkeydown = (e) => {
+ const cell = e.target.closest("[data-cell]");
+ if (!cell) return;
+ const i = Number(cell.dataset.cell);
+ if (["Enter", " "].includes(e.key)) {
+ e.preventDefault();
+ cellAction(i);
+ return;
+ }
+ if (e.key.startsWith("Arrow")) {
+ e.preventDefault();
+ const next = moveIndex(i, e.key, state.puzzle.rows, state.puzzle.cols);
+ if (next === i) return;
+ focused = next;
+ drawBoard();
+ $("board").querySelector(`[data-cell="${focused}"]`)?.focus();
+ }
+};
+$("edit-tool").onchange = () => {
+ state.selected = [];
+ render();
+};
+$("clear-selection").onclick = () => {
+ state.selected = [];
+ drawBoard();
+};
+$("save-cage").onclick = () => {
+ try {
+ const target = numberInput("cage-target");
+ if (!target || !state.selected.length)
+ throw Error("Select cage cells and enter a positive target.");
+ const cells = [...state.selected],
+ op = state.puzzle.type === "killersudoku" ? "+" : $("cage-op").value;
+ mutate(() => {
+ state.puzzle.cages = state.puzzle.cages.filter(
+ (q) => !q.cells.some((i) => cells.includes(i)),
+ );
+ state.puzzle.cages.push({
+ cells: cells.sort((a, b) => a - b),
+ target,
+ op,
+ });
+ cells.forEach((i) => state.cageUncertain.delete(i));
+ state.selected = [];
+ });
+ status(
+ "Cage saved.",
+ "Every cell must belong to exactly one cage before solving.",
+ );
+ } catch (e) {
+ fail(e);
+ }
+};
+$("remove-cage").onclick = () => {
+ if (!hasCageRemoval(state.puzzle, state.selected)) return;
+ mutate(() => {
+ state.puzzle.cages = state.puzzle.cages.filter(
+ (q) => !q.cells.some((i) => state.selected.includes(i)),
+ );
+ state.selected = [];
+ });
+};
+$("save-inequality").onclick = () => {
+ try {
+ if (state.selected.length !== 2)
+ throw Error("Select the smaller cell and its larger neighbour.");
+ const [less, greater] = state.selected,
+ p = state.puzzle;
+ if (
+ Math.abs(Math.floor(less / p.cols) - Math.floor(greater / p.cols)) +
+ Math.abs((less % p.cols) - (greater % p.cols)) !==
+ 1
+ )
+ throw Error("Inequality cells must share a side.");
+ mutate(() => {
+ p.inequalities = p.inequalities.filter(
+ (q) =>
+ ![less, greater].includes(q.less) ||
+ ![less, greater].includes(q.greater),
+ );
+ p.inequalities.push({ less, greater });
+ state.selected = [];
+ });
+ } catch (e) {
+ fail(e);
+ }
+};
+$("remove-inequality").onclick = () => {
+ if (!hasInequalityRemoval(state.puzzle, state.selected)) return;
+ mutate(() => {
+ state.puzzle.inequalities = state.puzzle.inequalities.filter(
+ (q) =>
+ !(
+ state.selected.includes(q.less) && state.selected.includes(q.greater)
+ ),
+ );
+ state.selected = [];
+ });
+};
+$("undo").onclick = () => {
+ const previous = state.history.pop();
+ if (!previous) return;
+ invalidate();
+ restoreEdit(state, previous);
+ persist();
+ render();
+ status("Last edit undone.");
+};
+$("stop").onclick = () => stopTask("Stopped.");
+function requestSolve() {
+ try {
+ checkSolveReady(state.puzzle);
+ if (reviewCells().size || state.needsReview) {
+ $("confirm-text").textContent =
+ `${TYPES[state.puzzle.type]} · ${state.puzzle.rows} × ${state.puzzle.cols}. ${reviewCells().size} cells were highlighted for review.`;
+ $("confirm-dialog").showModal();
+ } else solveNow();
+ } catch (e) {
+ fail(e);
+ }
+}
+function solveNow() {
+ try {
+ checkSolveReady(state.puzzle);
+ } catch (e) {
+ fail(e);
+ return;
+ }
+ state.uncertain.clear();
+ state.cageUncertain.clear();
+ state.needsReview = false;
+ state.notes = [];
+ state.result = null;
+ state.solution = 0;
+ state.view = "board";
+ persist();
+ render();
+ const id = begin();
+ if (!worker)
+ worker = new Worker(new URL("./solver-worker.js", import.meta.url), {
+ type: "module",
+ });
+ tasks.setDeadline(() => {
+ if (id === tasks.id)
+ stopTask("Runtime loading timed out. Go online and retry.");
+ }, 180000);
+ worker.onmessage = ({ data: m }) => {
+ if (m.id !== tasks.id) return;
+ if (m.type === "status") {
+ status(m.message, "Stop cancels this task.");
+ if (m.message.startsWith("Solving")) {
+ tasks.clearDeadline();
+ const seconds = Number($("time-limit").value);
+ if (seconds > 0)
+ tasks.setDeadline(() => {
+ if (id === tasks.id)
+ stopTask(
+ "Search limit reached. Increase the limit to continue from a fresh search.",
+ );
+ }, seconds * 1000);
+ }
+ return;
+ }
+ finish();
+ state.result = m.result;
+ render();
+ const r = m.result;
+ if (r.status === "unique")
+ status(
+ "Solved · unique solution",
+ `Completed and validated in ${r.elapsed.toFixed(2)} seconds. Original clues are preserved.`,
+ );
+ else if (r.status === "multiple")
+ status(
+ "More than one solution",
+ `At least two valid solutions exist. Check for a missed clue or an incorrect puzzle type. Use “Other solution” to compare.`,
+ "warning",
+ );
+ else if (r.status === "no-solution")
+ status(
+ "No solution to these clues.",
+ "Check the transcription, puzzle type and structural clues. This does not prove the photograph is wrong.",
+ "warning",
+ );
+ else
+ status(
+ r.status === "invalid"
+ ? "Check the puzzle data."
+ : "The solver could not finish.",
+ r.message || "Please retry.",
+ "error",
+ );
+ $("status").dataset.result = r.status;
+ };
+ worker.onerror = (e) => {
+ if (id !== tasks.id) return;
+ worker.terminate();
+ worker = null;
+ finish();
+ status(
+ "The solver stopped unexpectedly.",
+ e.message ||
+ "The phone may have run out of memory. Retry with other tabs closed.",
+ "error",
+ );
+ };
+ status("Starting the on-device solver…", "The first load downloads Python.");
+ worker.postMessage({ id, puzzle: clone(state.puzzle) });
+}
+$("solve").onclick = requestSolve;
+$("confirm-solve").onclick = () => {
+ $("confirm-dialog").close();
+ solveNow();
+};
+$("confirm-back").onclick = () => $("confirm-dialog").close();
+$("next-solution").onclick = () => {
+ if (state.result?.solutions?.length) {
+ state.solution = (state.solution + 1) % state.result.solutions.length;
+ render();
+ }
+};
+$("clean-view").onclick = () => {
+ state.view = "board";
+ render();
+};
+$("photo-view").onclick = () => {
+ state.view = "photo";
+ render();
+};
+$("example").onclick = () => {
+ try {
+ loadPuzzle(
+ demo(
+ $("puzzle-type").value === "auto" ? "sudoku" : $("puzzle-type").value,
+ ),
+ );
+ } catch (e) {
+ fail(e);
+ }
+};
+$("new-board").onclick = () => {
+ try {
+ const type =
+ $("puzzle-type").value === "auto" ? "sudoku" : $("puzzle-type").value,
+ n = ["sudoku", "killersudoku"].includes(type)
+ ? 9
+ : type === "kenken"
+ ? 6
+ : 5;
+ loadPuzzle(makePuzzle(type, n));
+ } catch (e) {
+ fail(e);
+ }
+};
+$("apply-layout").onclick = () => {
+ try {
+ const rows = Number($("rows").value),
+ cols = Number($("cols").value),
+ type =
+ $("puzzle-type").value === "auto"
+ ? state.puzzle.type
+ : $("puzzle-type").value;
+ const next = makePuzzle(type, rows, cols);
+ next.boxRows = Number($("box-rows").value);
+ next.boxCols = Number($("box-cols").value);
+ checkShape(next);
+ if (
+ type === state.puzzle.type &&
+ rows === state.puzzle.rows &&
+ cols === state.puzzle.cols
+ ) {
+ mutate(() => {
+ state.puzzle.boxRows = next.boxRows;
+ state.puzzle.boxCols = next.boxCols;
+ });
+ } else if (
+ confirm(
+ "Changing the board type or dimensions here clears existing clues. To keep clues while changing only the rules, use the button below the puzzle-type selector. Clear this board?",
+ )
+ )
+ loadPuzzle(next);
+ } catch (e) {
+ fail(e);
+ }
+};
+function download(blob, name) {
+ const a = document.createElement("a"),
+ url = URL.createObjectURL(blob);
+ a.href = url;
+ a.download = name;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 3000);
+}
+$("export-json").onclick = () =>
+ download(
+ new Blob([JSON.stringify(state.puzzle, null, 2)], {
+ type: "application/json",
+ }),
+ `gridpuzzle-${state.puzzle.type}.json`,
+ );
+$("save-photo").onclick = () => {
+ if (!canOverlay()) {
+ status("Read the adjusted crop before exporting an overlay.");
+ return;
+ }
+ drawOverlay();
+ $("solution-photo").toBlob((blob) => {
+ if (blob) download(blob, "gridpuzzle-solution.png");
+ });
+};
+$("import-json").onclick = () => $("json-file").click();
+$("json-file").onchange = async (e) => {
+ let id = tasks.id;
+ try {
+ const file = e.target.files[0];
+ if (!file) return;
+ if (file.size > 200000)
+ throw Error("Puzzle files must be smaller than 200 KB.");
+ stopTask();
+ id = tasks.id;
+ const parsed = JSON.parse(await file.text());
+ if (id === tasks.id) loadPuzzle(parsed);
+ } catch (error) {
+ if (id === tasks.id) fail(error);
+ } finally {
+ e.target.value = "";
+ }
+};
+$("apply-json").onclick = () => {
+ try {
+ if ($("json-data").value.length > 200000)
+ throw Error("Puzzle data is too large.");
+ loadPuzzle(JSON.parse($("json-data").value));
+ } catch (e) {
+ fail(e);
+ }
+};
+
+const { stopCamera } = setupPhotoFlow({
+ $,
+ state,
+ scanner,
+ stopTask,
+ invalidate,
+ begin,
+ finish,
+ fail,
+ render,
+ status,
+ remember,
+ persist,
+ drawBoard,
+ clearPhotoMapping,
+ solveNow,
+ boxDefault,
+ setLayout,
+ getJobId: () => tasks.id,
+ setDeadline: (callback, ms) => tasks.setDeadline(callback, ms),
+});
+window.addEventListener("pagehide", () => {
+ stopCamera();
+ stopTask();
+ if (worker) {
+ worker.terminate();
+ worker = null;
+ }
+});
+
+setupOffline($);
+try {
+ const saved = restoreSession(storage);
+ if (saved) {
+ state.puzzle = normalized(saved.puzzle);
+ state.uncertain = new Set(saved.uncertain);
+ state.cageUncertain = new Set(saved.cageUncertain);
+ state.needsReview = saved.needsReview;
+ state.notes = saved.notes;
+ }
+} catch {
+ /* Ignore malformed/old autosaves. */
+}
+render();
+$("build-label").textContent = "Browser scanner · __BUILD_ID__";
+document.body.dataset.ready = "true";
diff --git a/web/edit-history.js b/web/edit-history.js
new file mode 100644
index 00000000..e2e5a2e2
--- /dev/null
+++ b/web/edit-history.js
@@ -0,0 +1,26 @@
+import { clone } from "./model.js";
+export function captureEdit(state) {
+ return {
+ puzzle: clone(state.puzzle),
+ layout: state.layout ? clone(state.layout) : null,
+ uncertain: [...state.uncertain],
+ cageUncertain: [...(state.cageUncertain || [])],
+ needsReview: state.needsReview,
+ notes: [...state.notes],
+ source: state.puzzleSource,
+ };
+}
+export function restoreEdit(state, snapshot) {
+ state.puzzle = snapshot.puzzle;
+ state.layout = snapshot.layout || null;
+ state.uncertain = new Set(snapshot.uncertain);
+ state.cageUncertain = new Set(snapshot.cageUncertain || []);
+ state.needsReview = snapshot.needsReview;
+ state.notes = snapshot.notes;
+ state.puzzleSource = snapshot.source;
+ state.selected = [];
+}
+export function rememberEdit(state) {
+ state.history.push(captureEdit(state));
+ if (state.history.length > 30) state.history.shift();
+}
diff --git a/web/favicon.svg b/web/favicon.svg
new file mode 100644
index 00000000..c2b26cd3
--- /dev/null
+++ b/web/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/web/geometry-worker.js b/web/geometry-worker.js
new file mode 100644
index 00000000..d7756501
--- /dev/null
+++ b/web/geometry-worker.js
@@ -0,0 +1,25 @@
+import { findGrid, warp, estimateGrid, sharpness } from "./geometry.js";
+import { prepareScan } from "./scan-analysis.js";
+self.onmessage = ({ data }) => {
+ try {
+ let result;
+ if (data.op === "detect")
+ result = { ...findGrid(data.image), sharpness: sharpness(data.image) };
+ else if (data.op === "warp" || data.op === "prepare") {
+ const image = warp(data.image, data.corners, data.width, data.height);
+ result =
+ data.op === "prepare"
+ ? prepareScan(image, data.type, data.rows, data.cols)
+ : { image, meta: estimateGrid(image) };
+ } else throw Error("Unknown image task");
+ const transfer = result.image
+ ? [
+ result.image.data.buffer,
+ ...(result.mask ? [result.mask.buffer, result.g.buffer] : []),
+ ]
+ : [];
+ self.postMessage({ result }, transfer);
+ } catch (error) {
+ self.postMessage({ error: error.message });
+ }
+};
diff --git a/web/geometry.js b/web/geometry.js
new file mode 100644
index 00000000..d185db08
--- /dev/null
+++ b/web/geometry.js
@@ -0,0 +1,297 @@
+// Dependency-free image geometry. All expensive calls run in a Web Worker.
+export function gray(image) {
+ const out = new Uint8Array(image.width * image.height),
+ d = image.data;
+ for (let i = 0; i < out.length; i++)
+ out[i] = (77 * d[i * 4] + 150 * d[i * 4 + 1] + 29 * d[i * 4 + 2]) >> 8;
+ return out;
+}
+export function threshold(image, window = 25, bias = 12) {
+ return thresholdGray(gray(image), image.width, image.height, window, bias);
+}
+export function thresholdGray(g, w, h, window = 25, bias = 12) {
+ const sum = new Float64Array((w + 1) * (h + 1));
+ for (let y = 0; y < h; y++) {
+ let row = 0;
+ for (let x = 0; x < w; x++) {
+ row += g[y * w + x];
+ sum[(y + 1) * (w + 1) + x + 1] = sum[y * (w + 1) + x + 1] + row;
+ }
+ }
+ const out = new Uint8Array(w * h),
+ half = window >> 1;
+ for (let y = 0; y < h; y++)
+ for (let x = 0; x < w; x++) {
+ const a = Math.max(0, x - half),
+ b = Math.min(w, x + half + 1),
+ c = Math.max(0, y - half),
+ d = Math.min(h, y + half + 1);
+ const mean =
+ (sum[d * (w + 1) + b] -
+ sum[c * (w + 1) + b] -
+ sum[d * (w + 1) + a] +
+ sum[c * (w + 1) + a]) /
+ ((b - a) * (d - c));
+ out[y * w + x] = g[y * w + x] < Math.min(215, mean - bias) ? 1 : 0;
+ }
+ return out;
+}
+export function polygonArea(p) {
+ return (
+ Math.abs(
+ p.reduce((s, a, i) => {
+ const b = p[(i + 1) % p.length];
+ return s + a.x * b.y - a.y * b.x;
+ }, 0),
+ ) / 2
+ );
+}
+export function validQuad(p, w, h) {
+ if (
+ !Array.isArray(p) ||
+ p.length !== 4 ||
+ p.some(
+ (q) =>
+ !Number.isFinite(q.x) ||
+ !Number.isFinite(q.y) ||
+ q.x < 0 ||
+ q.y < 0 ||
+ q.x > w - 1 ||
+ q.y > h - 1,
+ )
+ )
+ return false;
+ const cross = p.map((a, i) => {
+ const b = p[(i + 1) % 4],
+ c = p[(i + 2) % 4];
+ return (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
+ });
+ return cross.every((n) => n > 1) && polygonArea(p) > w * h * 0.005;
+}
+// Homography maps unit-square coordinates to four clockwise image corners.
+export function homography(p) {
+ const [a, b, c, d] = p,
+ dx1 = b.x - c.x,
+ dx2 = d.x - c.x,
+ dx3 = a.x - b.x + c.x - d.x,
+ dy1 = b.y - c.y,
+ dy2 = d.y - c.y,
+ dy3 = a.y - b.y + c.y - d.y;
+ const det = dx1 * dy2 - dx2 * dy1;
+ let g = 0,
+ h = 0;
+ if (Math.abs(dx3) + Math.abs(dy3) > 1e-9) {
+ if (Math.abs(det) < 1e-9) throw Error("The crop corners are degenerate.");
+ g = (dx3 * dy2 - dx2 * dy3) / det;
+ h = (dx1 * dy3 - dx3 * dy1) / det;
+ }
+ return [
+ b.x - a.x + g * b.x,
+ d.x - a.x + h * d.x,
+ a.x,
+ b.y - a.y + g * b.y,
+ d.y - a.y + h * d.y,
+ a.y,
+ g,
+ h,
+ ];
+}
+export function project(m, u, v) {
+ const z = m[6] * u + m[7] * v + 1;
+ if (Math.abs(z) < 1e-10) throw Error("Invalid perspective.");
+ return {
+ x: (m[0] * u + m[1] * v + m[2]) / z,
+ y: (m[3] * u + m[4] * v + m[5]) / z,
+ };
+}
+export function warp(image, corners, width = 900, height = 900) {
+ if (!validQuad(corners, image.width, image.height))
+ throw Error("Keep the four crop corners clockwise without crossing.");
+ width = Math.max(32, Math.min(1600, Math.round(width)));
+ height = Math.max(32, Math.min(1600, Math.round(height)));
+ const m = homography(corners),
+ out = new Uint8ClampedArray(width * height * 4),
+ iw = image.width,
+ ih = image.height;
+ for (let y = 0; y < height; y++)
+ for (let x = 0; x < width; x++) {
+ const pt = project(m, x / (width - 1), y / (height - 1));
+ const sx = Math.max(0, Math.min(iw - 1, pt.x)),
+ sy = Math.max(0, Math.min(ih - 1, pt.y));
+ const x0 = Math.floor(sx),
+ y0 = Math.floor(sy),
+ x1 = Math.min(iw - 1, x0 + 1),
+ y1 = Math.min(ih - 1, y0 + 1),
+ fx = sx - x0,
+ fy = sy - y0;
+ const k = (y * width + x) * 4;
+ for (let ch = 0; ch < 3; ch++)
+ out[k + ch] =
+ (1 - fy) *
+ ((1 - fx) * image.data[(y0 * iw + x0) * 4 + ch] +
+ fx * image.data[(y0 * iw + x1) * 4 + ch]) +
+ fy *
+ ((1 - fx) * image.data[(y1 * iw + x0) * 4 + ch] +
+ fx * image.data[(y1 * iw + x1) * 4 + ch]);
+ out[k + 3] = 255;
+ }
+ return { width, height, data: out };
+}
+function groups(values, cutoff) {
+ const out = [];
+ let start = -1;
+ for (let i = 0; i <= values.length; i++) {
+ if (i < values.length && values[i] > cutoff) {
+ if (start < 0) start = i;
+ } else if (start >= 0) {
+ out.push({ at: (start + i - 1) / 2, width: i - start });
+ start = -1;
+ }
+ }
+ return out;
+}
+export function gridLines(image, mask) {
+ const w = image.width,
+ h = image.height,
+ b = mask ?? threshold(image),
+ x = new Float64Array(w),
+ y = new Float64Array(h);
+ for (let r = 0; r < h; r++)
+ for (let c = 0; c < w; c++) {
+ x[c] += b[r * w + c] / h;
+ y[r] += b[r * w + c] / w;
+ }
+ return { x: groups(x, 0.47), y: groups(y, 0.47) };
+}
+function regular(lines, length) {
+ if (lines.length < 4 || lines.length > 26) return 0;
+ const gap = (lines.at(-1).at - lines[0].at) / (lines.length - 1);
+ if (lines[0].at > length * 0.1 || lines.at(-1).at < length * 0.9) return 0;
+ return lines
+ .slice(1)
+ .every((l, i) => Math.abs(l.at - lines[i].at - gap) < gap * 0.23)
+ ? lines.length - 1
+ : 0;
+}
+export function estimateGrid(image, mask) {
+ const lines = gridLines(image, mask),
+ cols = regular(lines.x, image.width),
+ rows = regular(lines.y, image.height);
+ let boxes = false;
+ if (rows === cols && [4, 6, 9, 16, 25].includes(rows)) {
+ const mid = lines.x.slice(1, -1),
+ thin = Math.min(...mid.map((l) => l.width));
+ boxes = mid.some((l) => l.width > thin * 1.45 && l.width >= 3);
+ }
+ return { rows, cols, boxes, lines };
+}
+export function findGrid(image) {
+ const w = image.width,
+ h = image.height,
+ b = threshold(image),
+ seen = new Uint8Array(b.length),
+ queue = new Int32Array(b.length);
+ let best = null,
+ score = 0;
+ for (let i = 0; i < b.length; i++) {
+ if (!b[i] || seen[i]) continue;
+ let head = 0,
+ tail = 1;
+ queue[0] = i;
+ seen[i] = 1;
+ let minx = w,
+ maxx = 0,
+ miny = h,
+ maxy = 0,
+ minsum = Infinity,
+ maxsum = -Infinity,
+ mindiff = Infinity,
+ maxdiff = -Infinity;
+ let tl, tr, br, bl;
+ while (head < tail) {
+ const k = queue[head++],
+ x = k % w,
+ y = Math.floor(k / w);
+ minx = Math.min(minx, x);
+ maxx = Math.max(maxx, x);
+ miny = Math.min(miny, y);
+ maxy = Math.max(maxy, y);
+ if (x + y < minsum) {
+ minsum = x + y;
+ tl = { x, y };
+ }
+ if (x + y > maxsum) {
+ maxsum = x + y;
+ br = { x, y };
+ }
+ if (x - y < mindiff) {
+ mindiff = x - y;
+ bl = { x, y };
+ }
+ if (x - y > maxdiff) {
+ maxdiff = x - y;
+ tr = { x, y };
+ }
+ for (const j of [
+ x > 0 ? k - 1 : -1,
+ x < w - 1 ? k + 1 : -1,
+ y > 0 ? k - w : -1,
+ y < h - 1 ? k + w : -1,
+ ])
+ if (j >= 0 && b[j] && !seen[j]) {
+ seen[j] = 1;
+ queue[tail++] = j;
+ }
+ }
+ const area = (maxx - minx) * (maxy - miny),
+ corners = [tl, tr, br, bl];
+ if (
+ area > w * h * 0.07 &&
+ tail > 150 &&
+ maxx - minx > w * 0.15 &&
+ maxy - miny > h * 0.15 &&
+ validQuad(corners, w, h) &&
+ polygonArea(corners) > area * 0.5 &&
+ area > score
+ ) {
+ score = area;
+ best = corners;
+ }
+ }
+ if (!best)
+ return {
+ corners: [
+ { x: w * 0.08, y: h * 0.08 },
+ { x: w * 0.92, y: h * 0.08 },
+ { x: w * 0.92, y: h * 0.92 },
+ { x: w * 0.08, y: h * 0.92 },
+ ],
+ confidence: 0,
+ rows: 0,
+ cols: 0,
+ boxes: false,
+ };
+ const small = warp(image, best, 540, 540),
+ estimated = estimateGrid(small);
+ return {
+ corners: best,
+ confidence: estimated.rows && estimated.cols ? 0.94 : 0.45,
+ ...estimated,
+ lines: undefined,
+ };
+}
+export function sharpness(image) {
+ const g = gray(image),
+ w = image.width,
+ h = image.height;
+ let sum = 0,
+ count = 0;
+ for (let y = 1; y < h - 1; y += 2)
+ for (let x = 1; x < w - 1; x += 2) {
+ const i = y * w + x,
+ v = 4 * g[i] - g[i - 1] - g[i + 1] - g[i - w] - g[i + w];
+ sum += v * v;
+ count++;
+ }
+ return sum / Math.max(count, 1);
+}
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 00000000..52731cfd
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ GridPuzzle · Scan & solve
+
+
+
+
+
+
+
+ GridPuzzleSCAN & SOLVEOn-device solving
+
+
An updated version of the app is ready.
+
+
+
LESS COPYING. MORE DISCOVERY.
+
From paper to solved.
+
Point your camera at a puzzle. Check the clues. Let the complete GridPuzzle engine do the rest.
+
+
+
+
01
Bring a puzzle
+
+
+
+
+
Choose a type for the next scan, or apply it to the current board below. Automatic cannot infer invisible variant rules.
+
+
+ Grid size & settings
+
+
+
+
+
+
+
The full deduction hierarchy is retained. Search never runs on the interface thread.
+
+
+ Save, import & install
+
+
+
+
First use needs an internet connection.
+
On iPhone: Safari → Share → Add to Home Screen → Open as Web App. Photos stay on this device and are not uploaded. Your last puzzle is saved locally; photographs are not saved.
+
+
+
+
+
02
Your puzzle
+
Ready when you are.Scan a puzzle, load an example, or tap a cell to enter clues.
+
Keep the entire grid in view. Hold steady or tap Capture.
+
Drag the four corners onto the grid’s outside corners. Exclude titles and margins. Set the row/column count before reading.
+
+
Select the cells of a cage. Saving replaces any intersecting cages.
+
Tap the smaller cell, then its larger neighbour.
+
+
+
Original clueSolutionCheck reading
+
+
+
+
A unique solution verifies these clues—not the accuracy of the photograph’s transcription.
+ Advanced puzzle data
A data-only format for all eleven families. Cells are zero-based row-major indexes; null is blank, # is blocked, and Slitherlink 0 is a clue.