TTS lines the worker emitted during
+ # this run and look for CJK code points in the text the model produced.
+ tts_texts, _ = tail_llm_tts_text(log_offset)
+ cjk_lines = [t for t in tts_texts if CJK_RANGE.search(t) or CJK_ESCAPE.search(t)]
+ print("=" * 60)
+ print(f"text-only elapsed : {text_elapsed:6.2f}s (the *default* app path)")
+ print(f"chat+TTS elapsed : {tts_elapsed:6.2f}s (opt-in only)")
+ print(f"LLM->TTS chunks seen: {len(tts_texts)}")
+ print(f" chunks w/ CJK : {len(cjk_lines)}")
+ if cjk_lines:
+ print(f" example CJK chunk : {cjk_lines[0][:120]}")
+ print()
+ fast_default = text_elapsed < 15
+ print("default path fast? :", "PASS" if fast_default else "FAIL")
+ print("opt-in TTS English? :", "PASS" if not cjk_lines else "EXPECTED-FAIL (audio_assistant model is Chinese-prior)")
+ return 0 if fast_default else 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/validate_map_atlas.py b/scripts/validate_map_atlas.py
new file mode 100644
index 0000000000000000000000000000000000000000..1bafb5d07148527095bbdf7f6fac0fd2d77027b9
--- /dev/null
+++ b/scripts/validate_map_atlas.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+if str(PROJECT_ROOT) not in sys.path:
+ sys.path.insert(0, str(PROJECT_ROOT))
+
+from grid_map.atlas import validate_map_atlas
+
+
+def main() -> int:
+ errors = validate_map_atlas()
+ if errors:
+ for error in errors:
+ print(f"ERROR: {error}")
+ return 1
+ print("Map atlas is valid.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/ship_space.sh b/ship_space.sh
new file mode 100644
index 0000000000000000000000000000000000000000..4455d51248d3f68fc07c542691a00f5b3a880bec
--- /dev/null
+++ b/ship_space.sh
@@ -0,0 +1,171 @@
+#!/usr/bin/env bash
+###############################################################################
+# ship_space.sh — one-shot shipper for Phantom Grid -> Hugging Face Space.
+#
+# Copy this whole folder to a Linux machine (or run under WSL) and run:
+#
+# export HF_TOKEN=hf_xxx_your_WRITE_token # must be a WRITE token
+# ./ship_space.sh
+#
+# It will:
+# 1. Preflight: check tooling + that the HF token can WRITE.
+# 2. (optional) Build the Docker image locally and smoke-test it.
+# 3. Create the Space build-small-hackathon/phantom-grid (SDK: docker).
+# 4. Upload the app (excluding .venv/runtime/etc) + the Space README.
+# 5. Print the Space URL and the remaining manual steps.
+#
+# Flags:
+# --build Build the Docker image locally before pushing.
+# --smoke Build + run the container and probe health (implies --build).
+# --dry-run Do everything EXCEPT create_repo / upload (no network writes).
+# --no-push Alias for --dry-run.
+# -h | --help Show this help.
+#
+# Env overrides:
+# HF_TOKEN write token (required to push)
+# HF_ORG default: build-small-hackathon
+# HF_SPACE default: phantom-grid
+###############################################################################
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$HERE"
+
+HF_ORG="${HF_ORG:-build-small-hackathon}"
+HF_SPACE="${HF_SPACE:-phantom-grid}"
+REPO_ID="${HF_ORG}/${HF_SPACE}"
+IMAGE_TAG="phantom-grid:local"
+
+DO_BUILD=0
+DO_SMOKE=0
+DRY_RUN=0
+
+for arg in "$@"; do
+ case "$arg" in
+ --build) DO_BUILD=1 ;;
+ --smoke) DO_SMOKE=1; DO_BUILD=1 ;;
+ --dry-run|--no-push) DRY_RUN=1 ;;
+ -h|--help)
+ sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
+ exit 0 ;;
+ *) echo "Unknown flag: $arg" >&2; exit 2 ;;
+ esac
+done
+
+log() { printf '\033[1;36m[ship]\033[0m %s\n' "$*"; }
+warn() { printf '\033[1;33m[ship] WARN:\033[0m %s\n' "$*" >&2; }
+die() { printf '\033[1;31m[ship] ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
+
+###############################################################################
+# 1. Preflight
+###############################################################################
+log "Preflight checks..."
+command -v python3 >/dev/null || die "python3 is required."
+
+# huggingface_hub is needed to create + upload the Space.
+if ! python3 -c "import huggingface_hub" 2>/dev/null; then
+ log "Installing huggingface_hub..."
+ python3 -m pip install --quiet --upgrade "huggingface_hub>=1.2"
+fi
+
+# Validate required deliverable files exist.
+for f in Dockerfile entrypoint.sh .dockerignore requirements-space.txt README_SPACE.md app.py; do
+ [ -f "$f" ] || die "Missing required file: $f"
+done
+log "All required deployment files present."
+
+# Validate the app + entrypoint are syntactically sound before shipping.
+python3 -m py_compile app.py config/settings.py grid_map/map_loader.py llm/omni_client.py \
+ || die "Python syntax check failed."
+bash -n entrypoint.sh || die "entrypoint.sh has a syntax error."
+log "Syntax checks passed."
+
+###############################################################################
+# 2. Optional local Docker build / smoke test
+###############################################################################
+if [ "$DO_BUILD" = 1 ]; then
+ command -v docker >/dev/null || die "--build requested but docker is not installed."
+ log "Building Docker image ${IMAGE_TAG} (downloads the GGUF — this is slow)..."
+ docker build -t "$IMAGE_TAG" .
+ log "Docker build succeeded."
+fi
+
+if [ "$DO_SMOKE" = 1 ]; then
+ log "Smoke-testing the container..."
+ CID="$(docker run -d -p 7860:7860 "$IMAGE_TAG")"
+ cleanup_smoke() { docker rm -f "$CID" >/dev/null 2>&1 || true; }
+ trap cleanup_smoke EXIT
+ log "Container ${CID:0:12} started; waiting for the app to answer on :7860..."
+ ok=0
+ for i in $(seq 1 150); do
+ if curl -sf "http://127.0.0.1:7860/api/snapshot" >/dev/null 2>&1; then ok=1; break; fi
+ if ! docker ps -q --no-trunc | grep -q "$CID"; then
+ docker logs "$CID" | tail -40 >&2
+ die "Container exited during smoke test."
+ fi
+ sleep 4
+ done
+ [ "$ok" = 1 ] || { docker logs "$CID" | tail -40 >&2; die "App did not become healthy in time."; }
+ log "Smoke test passed: /api/snapshot responded."
+ cleanup_smoke
+ trap - EXIT
+fi
+
+###############################################################################
+# 3 + 4. Create the Space and upload
+###############################################################################
+if [ "$DRY_RUN" = 1 ]; then
+ log "--dry-run: skipping create_repo + upload. Everything else passed."
+ log "Would push to: https://huggingface.co/spaces/${REPO_ID}"
+ exit 0
+fi
+
+[ -n "${HF_TOKEN:-}" ] || die "HF_TOKEN is not set. Export a WRITE token: export HF_TOKEN=hf_..."
+
+log "Creating + uploading Space ${REPO_ID} ..."
+HF_ORG="$HF_ORG" HF_SPACE="$HF_SPACE" python3 - <<'PY'
+import os, sys
+from huggingface_hub import HfApi
+
+token = os.environ["HF_TOKEN"]
+org = os.environ["HF_ORG"]
+space = os.environ["HF_SPACE"]
+repo_id = f"{org}/{space}"
+api = HfApi(token=token)
+
+# Verify the token can write.
+who = api.whoami()
+perm = who.get("auth", {}).get("accessToken", {}).get("role") or who.get("auth", {}).get("type")
+print(f"[ship] Logged in as: {who.get('name')} (token role: {perm})")
+if perm == "read":
+ sys.exit("[ship] ERROR: this is a READ token. Create a WRITE token at "
+ "https://huggingface.co/settings/tokens and re-export HF_TOKEN.")
+
+api.create_repo(repo_id=repo_id, repo_type="space", space_sdk="docker", exist_ok=True)
+print(f"[ship] Space ready: https://huggingface.co/spaces/{repo_id}")
+
+ignore = [
+ ".git*", ".venv/*", "venv/*", "runtime/*", "tools/*",
+ "**/__pycache__/*", "*.pyc", "tmp/*", "*.log", "*.err.log",
+ "data/backups/*", "data/games/*", "data/archives/*",
+ "data/raw/archives/*", "data/*.log", "data/*.png",
+ "run_game.ps1", "run_game.cmd",
+ "README.md", "README_SPACE.md", "C:*",
+]
+api.upload_folder(
+ repo_id=repo_id, repo_type="space", folder_path=".",
+ ignore_patterns=ignore, commit_message="Ship Phantom Grid Docker Space",
+)
+# The Space README (with the docker frontmatter + track tag) goes in as README.md.
+api.upload_file(
+ path_or_fileobj="README_SPACE.md", path_in_repo="README.md",
+ repo_id=repo_id, repo_type="space", commit_message="Add Space README",
+)
+print(f"[ship] Upload complete. Build will start automatically.")
+print(f"[ship] Watch the build: https://huggingface.co/spaces/{repo_id}?logs=build")
+PY
+
+log "Done. Remaining manual steps:"
+log " - Record the demo video and paste its link into README_SPACE.md (then re-run, or edit on the Space)."
+log " - Publish the social post and add its link too."
+log " - Confirm the Space build goes RUNNING and a new case starts."
diff --git a/ui/__init__.py b/ui/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..df02c4fdbb48e9763b373963f1a7999903e86887
--- /dev/null
+++ b/ui/__init__.py
@@ -0,0 +1,2 @@
+"""UI components for the Phantom Grid investigation console."""
+
diff --git a/ui/components.py b/ui/components.py
new file mode 100644
index 0000000000000000000000000000000000000000..475113b759b16c1b3bb6a08f7c2a70a92f93e71c
--- /dev/null
+++ b/ui/components.py
@@ -0,0 +1,2 @@
+"""Shared Gradio component helpers will live here as the console grows."""
+
diff --git a/ui/game_log_panel.py b/ui/game_log_panel.py
new file mode 100644
index 0000000000000000000000000000000000000000..d49938f5f92dbf4617b7e1498118d93c4bf0a7c3
--- /dev/null
+++ b/ui/game_log_panel.py
@@ -0,0 +1,2 @@
+"""Game log rendering will live here."""
+
diff --git a/ui/map_view.py b/ui/map_view.py
new file mode 100644
index 0000000000000000000000000000000000000000..32be3e32a3eb9cc1d73089272d06c3833ce563ce
--- /dev/null
+++ b/ui/map_view.py
@@ -0,0 +1,2 @@
+"""Map layer rendering and click handling will live here."""
+
diff --git a/ui/notice_panel.py b/ui/notice_panel.py
new file mode 100644
index 0000000000000000000000000000000000000000..e4a55fbbb696b10297ba19124df8bde2ef84eb58
--- /dev/null
+++ b/ui/notice_panel.py
@@ -0,0 +1,2 @@
+"""Lookout notice controls will live here."""
+
diff --git a/ui/police_action_panel.py b/ui/police_action_panel.py
new file mode 100644
index 0000000000000000000000000000000000000000..4f0604a957e99e83bdfdb0b570bae325b77177c1
--- /dev/null
+++ b/ui/police_action_panel.py
@@ -0,0 +1,2 @@
+"""Route block and junction check controls will live here."""
+
diff --git a/ui/styles.css b/ui/styles.css
new file mode 100644
index 0000000000000000000000000000000000000000..61cee7e8ae6e6a56caf3c8feec6a0592ec79ff17
--- /dev/null
+++ b/ui/styles.css
@@ -0,0 +1,2 @@
+/* Reserved for the investigation console styling. */
+
diff --git a/ui/web/index.html b/ui/web/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..f1ea70b11543d85cc3cfc829b16fb5fde5e919ab
--- /dev/null
+++ b/ui/web/index.html
@@ -0,0 +1,400 @@
+
+
+
+
+
+ Phantom Grid
+
+
+
+
+
+
+
+
+
+ Wanted
+
+
+ Alias
+ John Doe
+ Description
+ Male, approx. 35-45. Gray raincoat. Carries red folder.
+ Last Seen
+ Awaiting confirmed location
+
+ £5,000 Reward
+ Dead or Alive
+
+
+
+ Active Units
+
+ 12 / 12 left
+
+
+ Advance Turn
+ New Case
+
+ Stop Game
+ Restart
+
+
+
+
+
+ Drag Tactics Onto The Map
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+ 145%
+ +
+ Reset
+
+ Witnesses
+ Tactics
+ Focus
+ Witness Mode
+
+
Drop tactics on junctions. Drag the map to navigate.
+
+
+
+
+
+
+
+ Commissioner's Notepad
+
+ Case Notes
+
+ Saved with this case.
+
+
+
+
+ Previous Witness Statements
+
+
+
+
+
+ Opening the board...
+
+
+
+
+
Phantom Grid Local AI
+
Preparing Your Investigation Desk
+
Checking the bundled llama.cpp runtime and MiniCPM-o model...
+
+
+ Pick the model and hardware Phantom Grid should run on. You can change these later from Settings.
+
+
+
+ Model variant
+
+ Smaller variants fit lower-VRAM cards; larger ones give cleaner answers.
+
+
+ Run on
+
+ Detected from your system. Pick CPU to skip GPU acceleration.
+
+
+ GPU offload
+
+ How many transformer layers to load onto the GPU. Auto is safe.
+
+
+ Context length
+
+ Larger context handles longer cases but uses more VRAM.
+
+
+
+
+
+
Checking setup
+
Preparing Local AI...
+
Advanced Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Lantern Watch Bureau / Priority Dossier
+
A New Case
+
+
+ Case Open
+
+
+
+ 01
+ The Crime
+
+
+ Stolen Victim
+
+
+ 02
+ The Thief
+
+
+
+
+
+ 03
+ The Trail
+ Last Seen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Witness Interview
+
Witness
+
+
+ Text ready End Interview
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/web/static/app.css b/ui/web/static/app.css
new file mode 100644
index 0000000000000000000000000000000000000000..46a1479404b31d8c29e686312803d87a378272bc
--- /dev/null
+++ b/ui/web/static/app.css
@@ -0,0 +1,1711 @@
+:root {
+ color-scheme: dark;
+ --teal: #073f42;
+ --teal-dark: #06292a;
+ --gold: #d6a33c;
+ --gold-bright: #ffd46f;
+ --paper: #f2d99c;
+ --paper-deep: #c9944d;
+ --ink: #2a160b;
+ --red: #a42b21;
+ --blue: #10537d;
+ --green: #176e5e;
+ --shadow: 0 18px 48px rgba(0, 0, 0, 0.42);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ color: #fff0bc;
+ font-family: Georgia, "Times New Roman", serif;
+ background:
+ linear-gradient(90deg, rgba(6, 41, 42, 0.9), rgba(65, 36, 10, 0.65)),
+ #241508;
+ overflow: hidden;
+}
+
+button,
+textarea {
+ font: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+button:disabled {
+ cursor: default;
+}
+
+.game-board {
+ width: min(1920px, 100vw);
+ height: 100vh;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ margin: 0 auto;
+ padding: 0 0 8px;
+ background:
+ repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025) 0 1px, transparent 1px 18px),
+ linear-gradient(180deg, #0c4141 0 15%, #261307 15% 100%);
+ border: 3px solid #8e5f18;
+ box-shadow: inset 0 0 0 3px #e0a83d, var(--shadow);
+}
+
+.top-banner {
+ height: 128px;
+ display: grid;
+ grid-template-columns: 288px minmax(0, 1fr) 172px 94px;
+ align-items: stretch;
+ gap: 10px;
+ padding: 6px 10px;
+ background:
+ linear-gradient(180deg, rgba(255, 233, 145, 0.16), transparent 32%),
+ linear-gradient(90deg, #063538, #07484b 52%, #063538);
+ border-bottom: 5px solid #b47b25;
+ box-shadow: inset 0 -2px 0 #ffd476;
+}
+
+.bureau-crest {
+ position: relative;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+}
+
+.bureau-crest img {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: fill;
+}
+
+.title-panel {
+ position: relative;
+ display: grid;
+ place-items: center;
+ align-content: center;
+ min-width: 0;
+}
+
+.title-panel:before,
+.title-panel:after {
+ content: "";
+ position: absolute;
+ top: 58%;
+ width: 120px;
+ height: 11px;
+ background: linear-gradient(90deg, transparent, var(--gold-bright), transparent);
+}
+
+.title-panel:before {
+ left: 34px;
+}
+
+.title-panel:after {
+ right: 34px;
+}
+
+.title-panel h1 {
+ margin: 0;
+ color: #ffc85f;
+ font-size: clamp(3.4rem, 7vw, 6.3rem);
+ line-height: 0.86;
+ letter-spacing: 0;
+ text-transform: uppercase;
+ text-shadow:
+ 0 3px 0 #5f3209,
+ 0 6px 0 #171008,
+ 0 0 18px rgba(255, 214, 105, 0.45);
+ white-space: nowrap;
+}
+
+.title-panel p {
+ margin: 10px 0 0;
+ color: #ffd46f;
+ font-size: 1.16rem;
+ font-weight: 950;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.title-panel h1[contenteditable="true"],
+.title-panel p[contenteditable="true"] {
+ max-width: 100%;
+ outline: 0;
+ cursor: text;
+}
+
+.title-panel h1[contenteditable="true"]:focus,
+.title-panel p[contenteditable="true"]:focus {
+ text-decoration: underline;
+ text-decoration-color: rgba(255, 212, 111, 0.8);
+ text-underline-offset: 0.12em;
+}
+
+.turn-panel,
+.gear-button {
+ border: 4px solid #b98127;
+ background:
+ linear-gradient(180deg, rgba(255, 213, 109, 0.15), transparent),
+ #08393c;
+ box-shadow: inset 0 0 0 2px #52320b, 0 4px 0 #301a05;
+}
+
+.turn-panel {
+ display: grid;
+ place-items: center;
+ align-content: center;
+ color: #ffd46f;
+ text-transform: uppercase;
+}
+
+.turn-panel span,
+.turn-panel small {
+ font-weight: 950;
+ letter-spacing: 0.08em;
+}
+
+.turn-panel strong {
+ color: #fff0bc;
+ font-size: 2.45rem;
+ line-height: 1;
+}
+
+.gear-button {
+ display: grid;
+ place-items: center;
+ color: #ffdf86;
+ font-size: 3.3rem;
+}
+
+.gear-button.muted {
+ opacity: 0.62;
+}
+
+.table-grid {
+ height: auto;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: 286px minmax(0, 1fr) 334px;
+ gap: 8px;
+ padding: 8px;
+}
+
+.left-rail,
+.right-rail,
+.center-stage {
+ min-height: 0;
+}
+
+.left-rail,
+.right-rail {
+ display: grid;
+ gap: 10px;
+}
+
+.left-rail {
+ grid-template-rows: minmax(0, 1fr) 92px 62px 46px 42px;
+}
+
+.right-rail {
+ grid-template-rows: minmax(0, 0.92fr) minmax(0, 1fr);
+}
+
+.wanted-card,
+.active-units,
+.lookout-board,
+.statements-panel {
+ border: 4px solid #b98127;
+ box-shadow: inset 0 0 0 2px #55320b, var(--shadow);
+}
+
+.wanted-card {
+ min-height: 0;
+ display: grid;
+ justify-items: center;
+ gap: 7px;
+ padding: 14px;
+ color: var(--ink);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.45), transparent 24%),
+ var(--paper);
+}
+
+.wanted-card h2 {
+ margin: 0;
+ color: #8b2217;
+ font-size: 3rem;
+ line-height: 0.95;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+
+.suspect-image {
+ width: 198px;
+ height: min(224px, 28vh);
+ object-fit: cover;
+ border: 2px solid #9e6d2c;
+ box-shadow: inset 0 0 0 3px rgba(255, 239, 185, 0.5);
+}
+
+.wanted-card dl {
+ width: 100%;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 4px 8px;
+ margin: 0;
+}
+
+.wanted-card dt {
+ color: #251304;
+ font-weight: 950;
+ text-transform: uppercase;
+}
+
+.wanted-card dd {
+ min-width: 0;
+ margin: 0;
+ color: #5c1f13;
+ font-weight: 800;
+ line-height: 1.02;
+}
+
+#wantedLastSeen {
+ color: #8b2217;
+}
+
+.wanted-card strong {
+ color: #4b2109;
+ font-size: 1.55rem;
+}
+
+.wanted-card small {
+ color: #4b2109;
+ font-weight: 950;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.active-units {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-rows: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 4px 8px;
+ padding: 9px 10px;
+ background:
+ repeating-linear-gradient(170deg, rgba(255, 255, 255, 0.04) 0 1px, transparent 1px 14px),
+ var(--teal);
+}
+
+.active-units h2,
+.lookout-board h2,
+.statements-panel h2 {
+ margin: 0;
+ color: #ffd46f;
+ font-size: 1.3rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ text-shadow: 0 2px 0 #311a06;
+}
+
+.active-units h2 {
+ grid-column: 1 / -1;
+ font-size: 1.08rem;
+}
+
+.unit-row {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: repeat(6, 15px);
+ gap: 4px;
+ align-content: center;
+}
+
+.unit-dot {
+ width: 15px;
+ height: 19px;
+ display: block;
+ border-radius: 50% 50% 7px 7px;
+ border: 1px solid rgba(255, 239, 185, 0.75);
+}
+
+.unit-dot.ready {
+ background:
+ radial-gradient(circle at 50% 24%, #a6e4ff 0 3px, transparent 4px),
+ linear-gradient(135deg, #1784bd, #0b4265);
+}
+
+.unit-dot.used {
+ background:
+ radial-gradient(circle at 50% 24%, #c2c2b6 0 3px, transparent 4px),
+ linear-gradient(135deg, #777d77, #3d413d);
+ opacity: 0.72;
+}
+
+.active-units strong {
+ justify-self: end;
+ color: #ffdf86;
+ font-size: 1.08rem;
+ white-space: nowrap;
+}
+
+.advance-button,
+.new-case-button,
+.search-button {
+ min-height: 50px;
+ color: #fff4c2;
+ font-size: 1.45rem;
+ font-weight: 950;
+ text-transform: uppercase;
+ border: 3px solid #a97522;
+ box-shadow: inset 0 0 0 2px rgba(255, 239, 185, 0.24), 0 5px 0 #2c1907;
+}
+
+.advance-button {
+ background: linear-gradient(180deg, #1d6796, #0c436e);
+}
+
+.advance-button.processing,
+.new-case-button.processing {
+ background: linear-gradient(180deg, #c8862c, #7a4e0d);
+ color: #1a0e02;
+ cursor: progress;
+ animation: advance-pulse 1.1s ease-in-out infinite;
+}
+
+@keyframes advance-pulse {
+ 0%, 100% { box-shadow: inset 0 0 0 2px rgba(255, 239, 185, 0.24), 0 5px 0 #2c1907, 0 0 0 0 rgba(255, 215, 100, 0.0); }
+ 50% { box-shadow: inset 0 0 0 2px rgba(255, 239, 185, 0.6), 0 5px 0 #2c1907, 0 0 16px 4px rgba(255, 215, 100, 0.55); }
+}
+
+.new-case-button {
+ background: linear-gradient(180deg, #25765f, #104d43);
+}
+
+.search-button {
+ background: linear-gradient(180deg, #c33d32, #8b2119);
+}
+
+.center-stage {
+ display: grid;
+ grid-template-rows: 130px minmax(0, 1fr);
+ gap: 8px;
+}
+
+.tactic-strip {
+ padding: 8px 12px 10px;
+ color: var(--ink);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.45), transparent 42%),
+ var(--paper);
+ border: 4px solid #b98127;
+ box-shadow: inset 0 0 0 2px #56330c, var(--shadow);
+}
+
+.tactic-strip h2 {
+ margin: 0 0 8px;
+ color: #143d3d;
+ font-size: 1.55rem;
+ text-align: center;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.tactic-tray {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(132px, 1fr));
+ gap: 9px;
+}
+
+.tactic-card {
+ position: relative;
+ height: 78px;
+ display: grid;
+ grid-template-columns: 66px minmax(0, 1fr);
+ grid-template-rows: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0 8px;
+ padding: 8px 10px;
+ color: var(--ink);
+ text-align: left;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.46), transparent 50%),
+ #f2d99c;
+ border: 3px solid #a97522;
+ box-shadow: inset 0 0 0 2px rgba(80, 43, 8, 0.25), 0 4px 0 rgba(54, 30, 5, 0.55);
+}
+
+.tactic-card:disabled {
+ filter: grayscale(0.65);
+ opacity: 0.58;
+}
+
+.tactic-card img {
+ grid-row: 1 / 3;
+ width: 62px;
+ height: 62px;
+ object-fit: contain;
+}
+
+.tactic-card span {
+ min-width: 0;
+ color: #201005;
+ font-size: 0.96rem;
+ font-weight: 950;
+ line-height: 1.02;
+ text-transform: uppercase;
+}
+
+.tactic-card strong {
+ color: #201005;
+ font-size: 1.15rem;
+}
+
+.tactic-preview {
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + 10px);
+ z-index: 30;
+ width: min(260px, 70vw);
+ display: none;
+ transform: translateX(-50%);
+ padding: 10px 12px;
+ color: #173a35;
+ font-size: 0.92rem;
+ font-style: normal;
+ line-height: 1.25;
+ background: #fff1bd;
+ border: 3px solid #a97522;
+ box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38);
+}
+
+.tactic-preview:after {
+ content: "";
+ position: absolute;
+ left: 50%;
+ bottom: -10px;
+ width: 16px;
+ height: 16px;
+ transform: translateX(-50%) rotate(45deg);
+ background: #fff1bd;
+ border-right: 3px solid #a97522;
+ border-bottom: 3px solid #a97522;
+}
+
+.tactic-card:hover .tactic-preview,
+.tactic-card:focus-visible .tactic-preview {
+ display: block;
+}
+
+.map-shell {
+ min-height: 0;
+ display: grid;
+ grid-template-rows: 38px minmax(0, 1fr) 62px;
+ padding: 0;
+}
+
+.layer-tabs {
+ display: flex;
+ align-items: end;
+ gap: 4px;
+ padding-left: 14px;
+}
+
+.layer-tabs button {
+ min-width: 112px;
+ height: 40px;
+ color: #ffdf86;
+ font-size: 1.16rem;
+ font-weight: 950;
+ background: linear-gradient(180deg, #0c5d5d, #063738);
+ border: 3px solid #a97522;
+ border-bottom: 0;
+ text-transform: capitalize;
+}
+
+.layer-tabs button.active {
+ color: #251304;
+ background: linear-gradient(180deg, #ffd461, #d4a231);
+}
+
+.map-wrap {
+ position: relative;
+ min-height: 0;
+ overflow: hidden;
+ background: #0a1414;
+ border: 5px solid #b98127;
+ border-radius: 16px 16px 0 0;
+ box-shadow: inset 0 0 0 3px #4c2c08, var(--shadow);
+ cursor: grab;
+ touch-action: none;
+}
+
+.map-wrap:active {
+ cursor: grabbing;
+}
+
+.map-canvas {
+ position: absolute;
+ inset: 0;
+ transform-origin: 0 0;
+ will-change: transform;
+}
+
+#mapImage {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ user-select: none;
+ pointer-events: none;
+}
+
+.overlay-layer {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+
+.map-controls {
+ position: absolute;
+ right: 12px;
+ top: 12px;
+ z-index: 18;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ max-width: calc(100% - 24px);
+ padding: 5px;
+ color: var(--ink);
+ background: rgba(242, 217, 156, 0.94);
+ border: 2px solid #a97522;
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.34);
+}
+
+.map-controls button,
+.map-controls output {
+ min-width: 34px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+ color: var(--ink);
+ font-weight: 950;
+ background: #ffd46f;
+ border: 1px solid #8f5d18;
+}
+
+.map-controls output {
+ min-width: 52px;
+ background: #fff0bc;
+}
+
+.map-control-divider {
+ width: 1px;
+ height: 24px;
+ background: #8f5d18;
+}
+
+.map-controls .map-visibility-toggle,
+.map-controls #witnessModeButton {
+ min-width: auto;
+ padding: 0 8px;
+ font-size: 0.72rem;
+}
+
+.map-controls .map-visibility-toggle:not(.active) {
+ color: #6c5c42;
+ background: #c8b98f;
+ text-decoration: line-through;
+}
+
+.map-controls #witnessModeButton.active {
+ color: #fff4c2;
+ background: #176e5e;
+}
+
+.focus-marker {
+ position: absolute;
+ width: 30px;
+ height: 30px;
+ transform: translate(-50%, -50%);
+ border: 3px solid #ffdf86;
+ border-radius: 50%;
+ box-shadow: 0 0 0 4px rgba(25, 105, 100, 0.35), 0 0 14px rgba(255, 212, 111, 0.62);
+}
+
+.map-token,
+.witness-token {
+ position: absolute;
+ pointer-events: auto;
+ border: 0;
+ background: transparent;
+ filter: drop-shadow(0 10px 8px rgba(0, 0, 0, 0.52));
+}
+
+.map-token {
+ width: 40px;
+ height: 48px;
+ transform: translate(
+ calc(-50% + var(--token-offset-x, 0px)),
+ calc(-82% + var(--token-offset-y, 0px))
+ );
+}
+
+.map-token.junction_lockdown {
+ width: 58px;
+ height: 44px;
+}
+
+.map-token.roadblock {
+ width: 32px;
+ height: 26px;
+}
+
+.map-token.patrol_unit {
+ width: 48px;
+ height: 54px;
+}
+
+.map-token.search_team {
+ width: 50px;
+ height: 50px;
+}
+
+.map-token.lookout_board {
+ width: 48px;
+ height: 58px;
+}
+
+.witness-token {
+ width: 38px;
+ height: 52px;
+ transform: translate(
+ calc(-50% + var(--token-offset-x, 0px)),
+ calc(-86% + var(--token-offset-y, 0px))
+ );
+}
+
+#selectionLayer { z-index: 10; }
+#tacticLayer { z-index: 11; }
+#witnessLayer { z-index: 12; }
+
+.map-token.co-located,
+.witness-token.co-located {
+ filter: drop-shadow(0 10px 8px rgba(0, 0, 0, 0.62));
+}
+
+.map-token img,
+.witness-token img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+.witness-token strong {
+ position: absolute;
+ right: -1px;
+ top: 4px;
+ min-width: 16px;
+ min-height: 16px;
+ display: grid;
+ place-items: center;
+ color: #fff4c2;
+ font-size: 0.62rem;
+ border-radius: 50%;
+ background: #8e2118;
+ border: 1px solid #ffdf86;
+}
+
+.witness-token.viewed strong {
+ background: #176e5e;
+}
+
+.witness-cluster-token {
+ z-index: 4;
+ width: 42px;
+ height: 56px;
+ transform: translate(-50%, -86%);
+}
+
+.witness-cluster-token strong {
+ right: -3px;
+ top: 1px;
+ min-width: 19px;
+ min-height: 19px;
+ font-size: 0.68rem;
+}
+
+.witness-cluster-member {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.cluster-report-list {
+ display: grid;
+ gap: 7px;
+ max-height: 230px;
+ overflow: auto;
+}
+
+.cluster-report-button {
+ display: grid;
+ gap: 3px;
+ padding: 8px;
+ color: #251304;
+ text-align: left;
+ background: #fff0bc;
+ border: 2px solid #a97522;
+}
+
+.cluster-report-button.viewed {
+ border-color: #176e5e;
+ background: #d7eee0;
+}
+
+.cluster-report-button span {
+ font-size: 0.76rem;
+ line-height: 1.25;
+}
+
+.cluster-report-button em {
+ font-style: normal;
+ font-size: 0.7rem;
+ font-weight: 700;
+ color: #6b3812;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.map-message {
+ position: absolute;
+ left: 14px;
+ right: 14px;
+ bottom: 12px;
+ min-height: 34px;
+ display: flex;
+ align-items: center;
+ padding: 0 12px;
+ color: #fff4c2;
+ font-weight: 800;
+ background: rgba(7, 63, 66, 0.88);
+ border: 2px solid rgba(255, 212, 111, 0.7);
+ pointer-events: none;
+}
+
+.legend-strip {
+ min-height: 62px;
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+ gap: 6px;
+ padding: 7px 9px;
+ color: var(--ink);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.45), transparent),
+ var(--paper);
+ border: 5px solid #b98127;
+ border-top: 0;
+ box-shadow: inset 0 0 0 2px #55320b;
+}
+
+.legend-item {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 42px minmax(0, 1fr);
+ grid-template-rows: 1fr 1fr;
+ align-items: center;
+ column-gap: 6px;
+}
+
+.legend-item img {
+ grid-row: 1 / 3;
+ width: 42px;
+ height: 52px;
+ object-fit: contain;
+}
+
+.legend-item strong {
+ overflow: hidden;
+ font-size: 0.76rem;
+ font-weight: 950;
+ text-overflow: ellipsis;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+.legend-item span {
+ overflow: hidden;
+ font-family: "Segoe Print", "Bradley Hand ITC", cursive;
+ font-size: 0.72rem;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lookout-board,
+.statements-panel {
+ min-height: 0;
+ display: grid;
+ gap: 8px;
+ padding: 12px;
+ background:
+ repeating-linear-gradient(172deg, rgba(255, 255, 255, 0.035) 0 1px, transparent 1px 16px),
+ var(--teal);
+}
+
+.lookout-board {
+ grid-template-rows: auto minmax(0, 1fr);
+}
+
+.statements-panel {
+ grid-template-rows: auto minmax(0, 1fr);
+}
+
+.paper-note {
+ min-height: 0;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto auto;
+ gap: 8px;
+ padding: 14px;
+ color: var(--ink);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.52), transparent 34%),
+ var(--paper);
+ border: 3px solid #a97522;
+ box-shadow: inset 0 0 0 2px rgba(86, 51, 12, 0.28);
+}
+
+.paper-note h3 {
+ margin: 0;
+ color: #8b2217;
+ text-align: center;
+ text-transform: uppercase;
+}
+
+.paper-note textarea {
+ width: 100%;
+ min-height: 126px;
+ resize: none;
+ color: #193b34;
+ font-family: "Segoe Print", "Bradley Hand ITC", cursive;
+ font-size: 1.05rem;
+ line-height: 1.42;
+ background:
+ repeating-linear-gradient(180deg, transparent 0 30px, rgba(35, 87, 72, 0.12) 31px),
+ rgba(255, 249, 218, 0.18);
+ border: 1px dashed rgba(80, 43, 8, 0.35);
+ outline: none;
+}
+
+.paper-note button,
+.ask-button,
+.remove-button {
+ min-height: 36px;
+ color: #fff4c2;
+ font-weight: 950;
+ background: linear-gradient(180deg, #25765f, #104d43);
+ border: 2px solid #a97522;
+}
+
+.paper-note p {
+ margin: 0;
+ color: #5b3110;
+ font-weight: 800;
+}
+
+.statement-list {
+ min-height: 0;
+ display: grid;
+ align-content: start;
+ gap: 8px;
+ overflow: auto;
+}
+
+.statement-card {
+ position: relative;
+ min-height: 78px;
+ padding: 9px 44px 9px 10px;
+ color: var(--ink);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.45), transparent 50%),
+ #f0d69a;
+ border: 2px solid #a97522;
+}
+
+.statement-card div {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.statement-card strong {
+ font-size: 0.92rem;
+ text-transform: uppercase;
+}
+
+.statement-card span {
+ color: #176e5e;
+ font-size: 0.72rem;
+ font-weight: 950;
+ text-transform: uppercase;
+}
+
+.statement-card p {
+ margin: 5px 0 0;
+ font-family: "Segoe Print", "Bradley Hand ITC", cursive;
+ font-size: 0.84rem;
+ line-height: 1.22;
+}
+
+.statement-card mark {
+ position: absolute;
+ right: 9px;
+ top: 50%;
+ width: 30px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+ transform: translateY(-50%);
+ color: #176e5e;
+ font-size: 1.35rem;
+ font-weight: 950;
+ background: transparent;
+ border: 3px solid #176e5e;
+ border-radius: 50%;
+}
+
+.statement-card.empty mark {
+ display: none;
+}
+
+.event-ticker {
+ margin: 0 8px;
+ min-height: 28px;
+ display: flex;
+ align-items: center;
+ padding: 0 12px;
+ color: #ffdf86;
+ background: rgba(0, 0, 0, 0.34);
+ border: 2px solid rgba(255, 212, 111, 0.45);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.detail-popup {
+ position: fixed;
+ z-index: 80;
+ width: 280px;
+ display: grid;
+ gap: 8px;
+ padding: 12px;
+ color: var(--ink);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.48), transparent 46%),
+ var(--paper);
+ border: 4px solid #a97522;
+ box-shadow: var(--shadow);
+}
+
+.drag-ghost {
+ position: fixed;
+ z-index: 120;
+ width: 136px;
+ min-height: 54px;
+ display: grid;
+ grid-template-columns: 46px minmax(0, 1fr);
+ align-items: center;
+ gap: 7px;
+ padding: 6px 9px;
+ color: var(--ink);
+ font-weight: 950;
+ text-transform: uppercase;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.48), transparent),
+ var(--paper);
+ border: 3px solid #a97522;
+ box-shadow: 0 16px 34px rgba(0, 0, 0, 0.45);
+ pointer-events: none;
+}
+
+.drag-ghost img {
+ width: 44px;
+ height: 44px;
+ object-fit: contain;
+}
+
+.drag-ghost span {
+ min-width: 0;
+ font-size: 0.78rem;
+ line-height: 1.05;
+}
+
+.detail-popup[hidden] {
+ display: none;
+}
+
+.detail-popup > img {
+ width: 58px;
+ height: 68px;
+ object-fit: contain;
+}
+
+.detail-popup h3 {
+ margin: 0;
+ color: #8b2217;
+ font-size: 1.1rem;
+ text-transform: uppercase;
+}
+
+.detail-popup p {
+ margin: 0;
+ line-height: 1.32;
+}
+
+.detail-popup dl {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 4px 8px;
+ margin: 0;
+}
+
+.detail-popup dt {
+ font-weight: 950;
+ text-transform: uppercase;
+}
+
+.detail-popup dd {
+ margin: 0;
+}
+
+.popup-close {
+ position: absolute;
+ right: 6px;
+ top: 5px;
+ width: 28px;
+ height: 28px;
+ color: #fff4c2;
+ background: #8b2217;
+ border: 2px solid #a97522;
+}
+
+.remove-button {
+ background: linear-gradient(180deg, #c33d32, #8b2119);
+}
+
+.settings-dialog {
+ width: min(760px, calc(100vw - 28px));
+ max-height: calc(100dvh - 28px);
+ color: var(--ink);
+ background: transparent;
+ border: 0;
+ padding: 0;
+}
+
+.settings-dialog::backdrop {
+ background: rgba(0, 0, 0, 0.58);
+}
+
+.settings-panel {
+ display: grid;
+ width: 100%;
+ box-sizing: border-box;
+ gap: 14px;
+ padding: 16px;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.42), transparent 42%),
+ var(--paper);
+ border: 5px solid #a97522;
+ box-shadow: var(--shadow);
+ max-height: calc(100dvh - 28px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ overscroll-behavior: contain;
+}
+
+.settings-panel > * {
+ min-width: 0;
+}
+
+.settings-panel header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.settings-panel h2 {
+ margin: 0;
+ color: #8b2217;
+ font-size: 2rem;
+ text-transform: uppercase;
+}
+
+.settings-panel header button {
+ width: 34px;
+ height: 34px;
+ color: #fff4c2;
+ background: #8b2217;
+ border: 2px solid #a97522;
+}
+
+.settings-grid {
+ display: grid;
+ min-width: 0;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.settings-grid label {
+ min-width: 0;
+ display: grid;
+ gap: 4px;
+ color: #482309;
+ font-weight: 950;
+ text-transform: uppercase;
+}
+
+.settings-wide {
+ grid-column: 1 / -1;
+}
+
+.settings-subgrid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ padding: 12px;
+ background: rgba(139, 34, 23, 0.07);
+ border: 2px dashed rgba(139, 34, 23, 0.45);
+}
+
+.settings-subgrid[hidden] {
+ display: none;
+}
+
+.settings-hint {
+ margin: 0;
+ color: #5f3215;
+ font-size: 0.82rem;
+ font-weight: 750;
+ line-height: 1.35;
+ text-transform: none;
+}
+
+.settings-grid input,
+.settings-grid select {
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+ min-height: 36px;
+ color: var(--ink);
+ background: #fff0bc;
+ border: 2px solid #a97522;
+ padding: 6px 8px;
+}
+
+.llama-status {
+ margin: 0;
+ padding: 10px;
+ color: #173a35;
+ font-weight: 850;
+ background: rgba(23, 110, 94, 0.12);
+ border-left: 5px solid #176e5e;
+ overflow-wrap: anywhere;
+}
+
+.settings-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.settings-actions button {
+ min-height: 38px;
+ padding: 0 12px;
+ color: #fff4c2;
+ font-weight: 950;
+ background: linear-gradient(180deg, #25765f, #104d43);
+ border: 2px solid #a97522;
+}
+
+@media (max-width: 700px) {
+ .settings-grid,
+ .settings-subgrid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.setup-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background: rgba(7, 17, 19, 0.82);
+ backdrop-filter: blur(6px);
+}
+
+.setup-overlay[hidden] {
+ display: none;
+}
+
+.setup-card {
+ width: min(620px, 92vw);
+ display: grid;
+ gap: 16px;
+ padding: 34px;
+ color: #3e210d;
+ text-align: center;
+ background: linear-gradient(145deg, #f8e7ad, #d7b86b);
+ border: 5px solid #6d3c12;
+ box-shadow: inset 0 0 0 3px #f8da82, 0 24px 70px #000;
+}
+
+.setup-card small {
+ font-weight: 950;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.setup-card h2 {
+ margin: 0;
+ font-size: clamp(2rem, 5vw, 3.4rem);
+ line-height: 0.95;
+ text-transform: uppercase;
+}
+
+.setup-card p {
+ margin: 0;
+ font-size: 1.08rem;
+ font-weight: 750;
+}
+
+.setup-card progress {
+ width: 100%;
+ height: 24px;
+ accent-color: #176e5e;
+}
+
+.setup-start-button {
+ min-height: 72px;
+ color: #fff4c2;
+ font-size: 1.8rem;
+ font-weight: 1000;
+ text-transform: uppercase;
+ background: linear-gradient(180deg, #2b8b69, #104d43);
+ border: 4px solid #a97522;
+ box-shadow: inset 0 0 0 2px rgba(255, 239, 185, 0.3), 0 7px 0 #2c1907;
+}
+
+.setup-start-button:disabled {
+ cursor: wait;
+ filter: grayscale(0.7);
+ opacity: 0.72;
+}
+
+.setup-overlay.ready .setup-start-button {
+ animation: setup-ready-pulse 1.8s ease-in-out infinite;
+}
+
+.setup-settings-button {
+ justify-self: center;
+ color: #4c2b12;
+ font-weight: 900;
+ background: transparent;
+ border: 0;
+ text-decoration: underline;
+}
+
+.setup-picker {
+ display: grid;
+ gap: 12px;
+ padding: 18px;
+ text-align: left;
+ background: rgba(255, 248, 222, 0.72);
+ border: 2px solid #a97522;
+ border-radius: 6px;
+}
+
+.setup-picker[hidden] { display: none; }
+
+.setup-picker-intro {
+ margin: 0;
+ font-size: 0.98rem;
+ font-weight: 750;
+ color: #4c2b12;
+}
+
+.setup-storage-hint {
+ padding: 8px 10px;
+ overflow-wrap: anywhere;
+ font-size: 0.82rem !important;
+ color: #4c2b12;
+ background: rgba(255, 255, 255, 0.42);
+ border-left: 4px solid #176e5e;
+}
+
+.setup-picker-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px 16px;
+}
+
+.setup-picker-grid label {
+ display: grid;
+ gap: 4px;
+ font-weight: 850;
+ font-size: 0.86rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #4c2b12;
+}
+
+.setup-picker-grid select {
+ padding: 8px 10px;
+ font-size: 0.95rem;
+ font-weight: 600;
+ color: #2c1907;
+ background: #fff8d8;
+ border: 2px solid #6d3c12;
+ border-radius: 4px;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.setup-picker-grid small {
+ font-weight: 500;
+ font-size: 0.78rem;
+ text-transform: none;
+ letter-spacing: 0;
+ color: #5a3e1f;
+}
+
+@media (max-width: 700px) {
+ .setup-picker-grid { grid-template-columns: minmax(0, 1fr); }
+}
+
+@keyframes setup-ready-pulse {
+ 50% { transform: scale(1.025); box-shadow: inset 0 0 0 2px rgba(255, 239, 185, 0.4), 0 10px 0 #2c1907, 0 0 30px rgba(255, 214, 101, 0.65); }
+}
+
+@media (max-width: 1200px) {
+ .top-banner {
+ height: auto;
+ grid-template-columns: 190px minmax(0, 1fr) 132px 72px;
+ }
+
+ .table-grid {
+ height: auto;
+ grid-template-columns: 220px minmax(0, 1fr);
+ }
+
+ .right-rail {
+ grid-column: 1 / -1;
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: minmax(260px, 1fr);
+ }
+
+ .tactic-tray {
+ grid-template-columns: repeat(3, minmax(132px, 1fr));
+ }
+
+ .center-stage {
+ grid-template-rows: auto minmax(620px, 68vh);
+ }
+}
+
+@media (max-width: 1400px) {
+ .top-banner {
+ grid-template-columns: 250px minmax(0, 1fr) 150px 78px;
+ }
+
+ .title-panel:before,
+ .title-panel:after {
+ display: none;
+ }
+
+ .title-panel h1 {
+ font-size: clamp(3rem, 5.4vw, 4.35rem);
+ }
+
+ .title-panel p {
+ font-size: 0.94rem;
+ }
+
+ .turn-panel strong {
+ font-size: 1.72rem;
+ }
+
+ .gear-button {
+ font-size: 2.5rem;
+ }
+
+ .table-grid {
+ grid-template-columns: 250px minmax(0, 1fr) 300px;
+ }
+
+ .wanted-card h2 {
+ font-size: 2.35rem;
+ }
+
+ .suspect-image {
+ width: 172px;
+ height: 206px;
+ }
+
+ .wanted-card {
+ gap: 5px;
+ padding: 10px;
+ }
+
+ .wanted-card dl {
+ font-size: 0.94rem;
+ gap: 3px 7px;
+ }
+
+ .wanted-card strong {
+ font-size: 1.25rem;
+ }
+
+ .tactic-tray {
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ }
+
+ .tactic-card {
+ grid-template-columns: 48px minmax(0, 1fr);
+ gap: 0 5px;
+ padding: 7px;
+ }
+
+ .tactic-card img {
+ width: 48px;
+ height: 48px;
+ }
+
+ .tactic-card span {
+ font-size: 0.78rem;
+ }
+
+ .tactic-card strong {
+ font-size: 0.95rem;
+ }
+
+ .right-rail {
+ min-width: 0;
+ }
+}
+
+@media (max-width: 760px) {
+ .top-banner,
+ .table-grid,
+ .right-rail {
+ grid-template-columns: 1fr;
+ }
+
+ .bureau-crest {
+ min-height: 126px;
+ }
+
+ .title-panel h1 {
+ font-size: 3rem;
+ white-space: normal;
+ text-align: center;
+ }
+
+ .table-grid {
+ min-height: 0;
+ }
+
+ .tactic-tray,
+ .legend-strip {
+ grid-template-columns: 1fr;
+ }
+
+ .map-shell {
+ grid-template-rows: auto minmax(520px, 65vh) auto;
+ }
+}
+
+body {
+ position: relative;
+}
+
+.game-board {
+ position: fixed;
+ left: 50%;
+ top: 50%;
+ width: 1672px;
+ height: 940px;
+ min-height: 0;
+ transform: translate(-50%, -50%) scale(var(--app-scale, 1));
+ transform-origin: center center;
+}
+
+.top-banner {
+ height: 142px;
+ grid-template-columns: 286px minmax(0, 1fr) 172px 94px;
+}
+
+.table-grid {
+ height: auto;
+ min-height: 0;
+ grid-template-columns: 286px minmax(0, 1fr) 334px;
+}
+
+.left-rail {
+ grid-template-rows: minmax(0, 1fr) 92px 62px 46px 42px;
+}
+
+.right-rail {
+ grid-column: auto;
+ min-width: 0;
+ grid-template-columns: 1fr;
+ grid-template-rows: minmax(0, 0.92fr) minmax(0, 1fr);
+}
+
+.center-stage {
+ grid-template-rows: 130px minmax(0, 1fr);
+}
+
+.tactic-tray {
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+}
+
+.map-shell {
+ grid-template-rows: 38px minmax(0, 1fr) 62px;
+}
+
+.legend-strip {
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+}
+
+.case-control-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
+.case-control-row button { color: #ffe8a7; font-weight: 900; background: #63251c; border: 2px solid #c28a35; }
+#notesText { min-height: 210px; resize: none; }
+#notesStatus { margin: 6px 0 0; opacity: 0.76; }
+
+.notice-dialog, .witness-dialog, .story-dialog { padding: 0; color: var(--ink); background: transparent; border: 0; }
+.notice-dialog::backdrop, .witness-dialog::backdrop, .story-dialog::backdrop { background: rgba(3, 14, 15, 0.9); backdrop-filter: blur(5px); }
+.notice-panel { width: min(720px, 92vw); display: grid; gap: 16px; padding: 22px; background: #f3dda6; border: 5px solid #b47b25; box-shadow: 0 24px 80px #000; }
+.notice-panel header, .witness-interview-shell > header, .story-reveal-shell > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
+.notice-panel h2, .witness-interview-shell h2, .story-reveal-shell h2 { margin: 0; color: #8b2217; text-transform: uppercase; }
+.notice-panel textarea { min-height: 220px; padding: 16px; color: #251304; background: #fff0bc; border: 2px solid #9a6825; resize: vertical; }
+.dialog-actions, .speech-controls, .text-chat-row { display: flex; gap: 10px; }
+.dialog-actions button, .witness-interview-shell button, .story-reveal-shell button { min-height: 40px; padding: 8px 14px; color: #fff0bc; font-weight: 900; background: #0c5552; border: 2px solid #c18a34; }
+.witness-dialog, .story-dialog { width: 100vw; max-width: none; height: 100vh; max-height: none; }
+.witness-interview-shell, .story-reveal-shell { width: 100%; height: 100%; display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; gap: 14px; padding: clamp(18px, 3vw, 48px); color: #f8e7b3; background: radial-gradient(circle at 20% 10%, rgba(211, 159, 55, 0.16), transparent 34%), linear-gradient(145deg, #052d30, #171006 70%); border: 8px solid #9f6d22; }
+.witness-interview-shell h2, .story-reveal-shell h2 { color: #ffd46f; font-size: clamp(2rem, 4vw, 4rem); }
+.witness-interview-shell header p, .witness-interview-shell header small, .story-reveal-shell header small { margin: 4px 0; color: #e8c978; }
+.witness-summary { padding: 14px 18px; color: #2a160b; background: #eed69b; border-left: 6px solid #9e2c20; }
+.witness-transcript, .story-timeline { min-height: 0; overflow: auto; padding: 18px; background: rgba(0, 0, 0, 0.27); border: 2px solid #8c672d; }
+.chat-message { width: min(76%, 820px); margin: 10px 0; padding: 12px 14px; border-radius: 8px; line-height: 1.45; }
+.chat-message.user { margin-left: auto; color: #f9ebbd; background: #175e65; }
+.chat-message.witness { color: #2b1709; background: #eed69b; }
+.witness-interview-shell footer { display: grid; gap: 10px; }
+.speech-controls { align-items: center; flex-wrap: wrap; }
+#pushToTalkButton.recording, #autoSpeechButton.active { background: #9b2a20; box-shadow: 0 0 20px rgba(255, 85, 62, 0.6); }
+#micLevel { width: 180px; }
+.text-chat-row textarea { min-height: 58px; flex: 1; padding: 10px; color: #241307; background: #fff0bc; resize: none; }
+.story-reveal-shell { grid-template-rows: auto minmax(0, 1fr) auto; }
+.story-card { margin: 0 0 16px; padding: 18px; color: #28170a; background: #efd99f; border-left: 7px solid #9b2a20; }
+.story-card h3 { margin: 0 0 8px; color: #7f2219; }
+.story-facts { margin: 10px 0 0; color: #4c3318; }
+
+.case-intro-dialog { width: 100vw; max-width: none; height: 100vh; max-height: none; padding: 0; color: #f7e7b5; background: transparent; border: 0; }
+.case-intro-dialog::backdrop { background: rgba(1, 8, 10, 0.94); backdrop-filter: blur(7px); }
+.case-intro-shell { box-sizing: border-box; width: 100%; height: 100%; display: grid; grid-template-rows: auto 1fr auto; gap: clamp(18px, 3vh, 34px); padding: clamp(24px, 4vw, 64px); overflow: auto; background: radial-gradient(circle at 82% 12%, rgba(183, 44, 29, 0.2), transparent 30%), radial-gradient(circle at 12% 80%, rgba(199, 151, 51, 0.15), transparent 34%), #071f21; border: 10px solid #9a6926; box-shadow: inset 0 0 90px #000; }
+.case-intro-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid rgba(232, 195, 104, 0.4); padding-bottom: 18px; }
+.case-intro-heading small, .intro-card > small { color: #d9ae50; font-weight: 900; letter-spacing: 0.18em; text-transform: uppercase; }
+.case-intro-heading h2 { margin: 5px 0 0; color: #ffe39a; font-size: clamp(2.4rem, 5vw, 5.2rem); line-height: 0.92; text-transform: uppercase; text-shadow: 4px 5px 0 #3f1710; }
+.case-intro-heading p { max-width: 760px; margin: 12px 0 0; color: #d8c38f; font-size: 1.08rem; }
+.case-stamp { padding: 12px 18px; color: #d4513e; font: 900 1.15rem Georgia, serif; letter-spacing: 0.14em; text-transform: uppercase; border: 4px double #b43a2b; transform: rotate(5deg); }
+.case-intro-cards { display: grid; grid-template-columns: 1.25fr 0.8fr 1.15fr; gap: clamp(14px, 2vw, 28px); align-items: stretch; }
+.intro-card { position: relative; min-height: 430px; padding: clamp(22px, 2.5vw, 38px); color: #281707; overflow: hidden; background: linear-gradient(145deg, #f5e3ac, #d8b96f); border: 1px solid #fff0be; box-shadow: 0 22px 50px rgba(0,0,0,0.5), inset 0 0 35px rgba(91,45,12,0.18); transform: rotate(-0.7deg); }
+.intro-card:nth-child(2) { transform: translateY(10px) rotate(0.8deg); }
+.intro-card:nth-child(3) { transform: rotate(-0.3deg); }
+.intro-card:after { content: ""; position: absolute; inset: 10px; pointer-events: none; border: 1px solid rgba(107, 58, 17, 0.35); }
+.intro-card-number { position: absolute; top: 10px; right: 20px; color: rgba(116, 57, 16, 0.16); font: 900 5rem Georgia, serif; }
+.intro-card h3 { position: relative; margin: 14px 0; color: #7f2118; font: 900 clamp(1.55rem, 2.4vw, 2.7rem) Georgia, serif; line-height: 1; text-transform: uppercase; }
+.intro-card p { position: relative; line-height: 1.55; }
+.crime-card dl { position: relative; display: grid; grid-template-columns: 72px 1fr; gap: 8px 12px; margin-top: 24px; padding-top: 18px; border-top: 2px solid rgba(112, 62, 19, 0.35); }
+.crime-card dt { color: #8d271b; font-weight: 900; text-transform: uppercase; }
+.crime-card dd { margin: 0; font-weight: 700; }
+.suspect-card { text-align: center; background: linear-gradient(160deg, #d9c17b, #ae8946); }
+.suspect-card img { position: relative; width: min(90%, 230px); height: 245px; margin: 14px auto 4px; object-fit: cover; filter: sepia(0.8) contrast(1.2); border: 7px solid #f2dda0; box-shadow: 0 5px 15px rgba(48, 25, 5, 0.45); }
+.suspect-card p { padding: 12px; color: #f8e8b7; background: #4e2615; }
+.sightings-card ol { position: relative; display: grid; gap: 10px; margin: 18px 0 0; padding: 0; list-style: none; }
+.sightings-card li { display: grid; grid-template-columns: 40px 1fr; gap: 12px; padding: 12px; background: rgba(255,247,211,0.45); border-left: 5px solid #856632; }
+.sightings-card li.sighting-confirmed { background: rgba(153, 39, 26, 0.12); border-color: #9a2a1d; }
+.sightings-card li > span { color: #9a2a1d; font: 900 1.35rem Georgia, serif; }
+.sightings-card li small, .sightings-card li strong { display: block; }
+.sightings-card li small { color: #75521e; font-weight: 900; text-transform: uppercase; }
+.sightings-card li strong { margin: 2px 0 4px; font-size: 1.08rem; }
+.sightings-card li p { margin: 0; font-size: 0.9rem; line-height: 1.35; }
+.case-intro-shell footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; color: #d7bd79; }
+.case-intro-shell footer button { min-width: 240px; padding: 15px 24px; color: #fff1bd; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; background: linear-gradient(#a73a29, #702116); border: 3px solid #d7a64d; box-shadow: 0 8px 0 #32100c; }
+.case-intro-shell footer button:hover { transform: translateY(-2px); box-shadow: 0 10px 0 #32100c; }
+
+@media (max-width: 1000px) {
+ .case-intro-cards { grid-template-columns: 1fr; }
+ .intro-card { min-height: 0; transform: none !important; }
+ .case-intro-heading { align-items: flex-start; }
+}
+
+/* How-to-play tutorial -------------------------------------------------- */
+.help-button { font: 900 1.5rem Georgia, serif; }
+.tutorial-dialog { width: 100vw; max-width: none; height: 100vh; max-height: none; padding: 0; color: #f7e7b5; background: transparent; border: 0; }
+.tutorial-dialog::backdrop { background: rgba(1, 8, 10, 0.95); backdrop-filter: blur(7px); }
+.tutorial-shell { box-sizing: border-box; width: 100%; height: 100%; display: grid; grid-template-rows: auto 1fr auto; gap: clamp(16px, 2.6vh, 30px); padding: clamp(22px, 3.4vw, 56px); overflow: auto; background: radial-gradient(circle at 82% 12%, rgba(183, 44, 29, 0.18), transparent 30%), radial-gradient(circle at 12% 80%, rgba(199, 151, 51, 0.14), transparent 34%), #071f21; border: 10px solid #9a6926; box-shadow: inset 0 0 90px #000; }
+.tutorial-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid rgba(232, 195, 104, 0.4); padding-bottom: 14px; }
+.tutorial-head small { color: #d9ae50; font-weight: 900; letter-spacing: 0.18em; text-transform: uppercase; }
+.tutorial-head h2 { margin: 5px 0 0; color: #ffe39a; font-size: clamp(1.9rem, 3.6vw, 3.4rem); line-height: 0.95; text-transform: uppercase; text-shadow: 3px 4px 0 #3f1710; }
+.tutorial-skip { padding: 10px 18px; color: #e7cd8a; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; background: transparent; border: 2px solid rgba(215, 166, 77, 0.6); border-radius: 4px; cursor: pointer; }
+.tutorial-skip:hover { color: #fff1bd; border-color: #d7a64d; }
+.tutorial-body { display: grid; grid-template-columns: 1.55fr 1fr; gap: clamp(18px, 2.5vw, 40px); align-items: center; min-height: 0; }
+.tutorial-figure { margin: 0; padding: 12px; background: linear-gradient(145deg, #16322f, #0a2422); border: 4px solid #9a6926; box-shadow: 0 22px 50px rgba(0,0,0,0.55); }
+.tutorial-figure img { display: block; width: 100%; height: auto; max-height: 64vh; object-fit: contain; border: 1px solid rgba(0,0,0,0.5); }
+.tutorial-copy { display: grid; gap: 14px; align-content: center; }
+.tutorial-tag { justify-self: start; padding: 6px 14px; color: #1c1305; font: 900 0.82rem Georgia, serif; letter-spacing: 0.14em; text-transform: uppercase; background: linear-gradient(#f5e3ac, #d8b96f); border: 2px solid #fff0be; }
+.tutorial-copy h3 { margin: 0; color: #ffe39a; font: 900 clamp(1.6rem, 2.6vw, 2.8rem) Georgia, serif; line-height: 1.02; text-transform: uppercase; }
+.tutorial-text { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
+.tutorial-text li { position: relative; padding-left: 26px; color: #e8d5a0; font-size: clamp(0.98rem, 1.2vw, 1.18rem); line-height: 1.45; }
+.tutorial-text li::before { content: ""; position: absolute; left: 4px; top: 0.55em; width: 9px; height: 9px; background: #d4513e; transform: rotate(45deg); box-shadow: 0 0 0 2px rgba(215, 166, 77, 0.5); }
+.tutorial-text li strong { color: #ffe39a; }
+.tutorial-foot { display: flex; align-items: center; justify-content: space-between; gap: 20px; border-top: 1px solid rgba(232, 195, 104, 0.4); padding-top: 14px; }
+.tutorial-dots { display: flex; gap: 10px; }
+.tutorial-dots button { width: 13px; height: 13px; padding: 0; background: rgba(215, 166, 77, 0.3); border: 2px solid #8a6427; border-radius: 50%; cursor: pointer; }
+.tutorial-dots button.active { background: #d4513e; border-color: #f0c267; }
+.tutorial-nav { display: flex; align-items: center; gap: 14px; }
+.tutorial-counter { color: #c9ad6c; font-weight: 800; letter-spacing: 0.08em; }
+.tutorial-nav button { min-width: 130px; padding: 13px 22px; color: #fff1bd; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; background: linear-gradient(#23423d, #15302d); border: 3px solid #d7a64d; box-shadow: 0 6px 0 #0a1c1a; cursor: pointer; }
+.tutorial-nav button.tutorial-primary { background: linear-gradient(#a73a29, #702116); box-shadow: 0 6px 0 #32100c; }
+.tutorial-nav button:hover { transform: translateY(-2px); }
+.tutorial-nav button:disabled { opacity: 0.4; cursor: default; transform: none; }
+
+@media (max-width: 1000px) {
+ .tutorial-body { grid-template-columns: 1fr; }
+ .tutorial-figure img { max-height: 40vh; }
+}
+
+@media (max-height: 800px) and (min-width: 1001px) {
+ .case-intro-shell { gap: 14px; padding: 22px 42px; }
+ .case-intro-heading { padding-bottom: 10px; }
+ .case-intro-heading h2 { font-size: 3rem; }
+ .case-intro-heading p { margin-top: 7px; }
+ .intro-card { min-height: 360px; padding: 20px 25px; }
+ .intro-card h3 { font-size: 1.7rem; }
+ .suspect-card img { width: 180px; height: 195px; margin-top: 8px; }
+ .sightings-card ol { gap: 7px; margin-top: 10px; }
+ .sightings-card li { padding: 8px 10px; }
+ .sightings-card li p { font-size: 0.8rem; }
+ .case-intro-shell footer button { padding: 11px 20px; }
+}
diff --git a/ui/web/static/app.js b/ui/web/static/app.js
new file mode 100644
index 0000000000000000000000000000000000000000..bbef1e51eea2c10292593c67ae21ae33c7c50f0f
--- /dev/null
+++ b/ui/web/static/app.js
@@ -0,0 +1,2200 @@
+const DEFAULT_NOTICE = "Request high-confidence reports of a grey raincoat carrying a red folder at the selected junction.";
+const DEFAULT_FOCUSED_JUNCTION = 100;
+
+const ASSET = "/static/assets/reference/";
+const ASSET_VERSION = "20260614-complete-icons-v3";
+
+function assetUrl(filename) {
+ return `${ASSET}${filename}?v=${ASSET_VERSION}`;
+}
+
+const TACTICS = {
+ roadblock: {
+ label: "Roadblock",
+ countLabel: "3 units",
+ icon: "icon_roadblock.png",
+ pin: "pin_roadblock.png",
+ preview: "Blocks one road route from this junction. Best for cutting off a known escape path.",
+ details: "Blocks one open route leaving this junction for two turns.",
+ },
+ junction_lockdown: {
+ label: "Junction Lockdown",
+ countLabel: "3 units",
+ icon: "icon_junction_lockdown.png",
+ pin: "pin_junction_lockdown.png",
+ preview: "Locks down movement through this junction for a short time. Best at chokepoints.",
+ details: "Blocks movement through this junction for two turns.",
+ },
+ patrol_unit: {
+ label: "Patrol Unit",
+ countLabel: "2 units",
+ icon: "icon_patrol_unit.png",
+ pin: "pin_patrol_unit.png",
+ preview: "Deters the suspect AND files a high-reliability sighting if they pass through or near this junction.",
+ details: "The culprit avoids this junction. If they still pass through or next to it, the patrol officer files a witness report.",
+ },
+ search_team: {
+ label: "Search Team",
+ countLabel: "2 units",
+ icon: "icon_search_team.png",
+ pin: "pin_search_team.png",
+ preview: "Stakes out this junction. If the suspect passes through, the case ends instantly.",
+ details: "Wins the case if the culprit is at this junction at any point during the next turn.",
+ },
+ lookout_board: {
+ label: "Lookout Board",
+ countLabel: "2 units",
+ icon: "icon_lookout_board.png",
+ pin: "pin_lookout_board.png",
+ preview: "Posts a public notice here. People nearby are more likely to report sightings after seeing it.",
+ details: "Increases nearby witness response when a lookout notice is raised.",
+ },
+};
+
+const LAYER_LABELS = {
+ normal: "Normal",
+ taxi: "Taxi",
+ bus: "Bus",
+ subway: "Subway",
+};
+
+const LAYER_Y_OFFSET = {
+ normal: 0,
+ taxi: 86,
+ bus: 86,
+ subway: 86,
+};
+
+const LAYER_MODE = {
+ taxi: "taxi",
+ bus: "bus",
+ subway: "subway",
+};
+
+function currentLayerYOffset() {
+ return LAYER_Y_OFFSET[state.layer] || 0;
+}
+
+const els = {
+ caseClock: document.querySelector("#caseClock"),
+ turnPhase: document.querySelector("#turnPhase"),
+ settingsButton: document.querySelector("#settingsButton"),
+ newCaseButton: document.querySelector("#newCaseButton"),
+ stopGameButton: document.querySelector("#stopGameButton"),
+ restartGameButton: document.querySelector("#restartGameButton"),
+ advanceButton: document.querySelector("#advanceButton"),
+ activeUnitsText: document.querySelector("#activeUnitsText"),
+ unitIcons: document.querySelector("#unitIcons"),
+ tacticTray: document.querySelector("#tacticTray"),
+ layerTabs: document.querySelector("#layerTabs"),
+ mapWrap: document.querySelector("#mapWrap"),
+ mapCanvas: document.querySelector("#mapCanvas"),
+ mapImage: document.querySelector("#mapImage"),
+ selectionLayer: document.querySelector("#selectionLayer"),
+ witnessLayer: document.querySelector("#witnessLayer"),
+ tacticLayer: document.querySelector("#tacticLayer"),
+ mapMessage: document.querySelector("#mapMessage"),
+ legendStrip: document.querySelector("#legendStrip"),
+ notesText: document.querySelector("#notesText"),
+ notesStatus: document.querySelector("#notesStatus"),
+ statementList: document.querySelector("#statementList"),
+ eventTicker: document.querySelector("#eventTicker"),
+ detailPopup: document.querySelector("#detailPopup"),
+ wantedDescription: document.querySelector("#wantedDescription"),
+ wantedLastSeen: document.querySelector("#wantedLastSeen"),
+ suspectImage: document.querySelector("#suspectImage"),
+ wantedAlias: document.querySelector("#wantedAlias"),
+ gameTitle: document.querySelector("#gameTitle"),
+ gameSubtitle: document.querySelector("#gameSubtitle"),
+ zoomOutButton: document.querySelector("#zoomOutButton"),
+ zoomInButton: document.querySelector("#zoomInButton"),
+ zoomResetButton: document.querySelector("#zoomResetButton"),
+ zoomValue: document.querySelector("#zoomValue"),
+ toggleWitnessesButton: document.querySelector("#toggleWitnessesButton"),
+ toggleTacticsButton: document.querySelector("#toggleTacticsButton"),
+ toggleFocusButton: document.querySelector("#toggleFocusButton"),
+ witnessModeButton: document.querySelector("#witnessModeButton"),
+ settingsDialog: document.querySelector("#settingsDialog"),
+ settingsCloseButton: document.querySelector("#settingsCloseButton"),
+ soundSetting: document.querySelector("#soundSetting"),
+ difficultySetting: document.querySelector("#difficultySetting"),
+ providerSetting: document.querySelector("#providerSetting"),
+ customModelSettings: document.querySelector("#customModelSettings"),
+ llamaConnectionSettings: document.querySelector("#llamaConnectionSettings"),
+ externalServerHint: document.querySelector("#externalServerHint"),
+ modelPathSetting: document.querySelector("#modelPathSetting"),
+ serverBinSetting: document.querySelector("#serverBinSetting"),
+ baseUrlSetting: document.querySelector("#baseUrlSetting"),
+ llmModelSetting: document.querySelector("#llmModelSetting"),
+ gatewayUrlSetting: document.querySelector("#gatewayUrlSetting"),
+ launcherPathSetting: document.querySelector("#launcherPathSetting"),
+ comniCheckoutSetting: document.querySelector("#comniCheckoutSetting"),
+ omniRootSetting: document.querySelector("#omniRootSetting"),
+ modelDirSetting: document.querySelector("#modelDirSetting"),
+ quantizationSetting: document.querySelector("#quantizationSetting"),
+ contextLengthSetting: document.querySelector("#contextLengthSetting"),
+ gpuLayersSetting: document.querySelector("#gpuLayersSetting"),
+ voiceDirSetting: document.querySelector("#voiceDirSetting"),
+ llamaStatusText: document.querySelector("#llamaStatusText"),
+ settingsSaveButton: document.querySelector("#settingsSaveButton"),
+ llamaStartButton: document.querySelector("#llamaStartButton"),
+ llamaRestartButton: document.querySelector("#llamaRestartButton"),
+ llamaStopButton: document.querySelector("#llamaStopButton"),
+ noticeDialog: document.querySelector("#noticeDialog"),
+ noticeCloseButton: document.querySelector("#noticeCloseButton"),
+ noticeCancelButton: document.querySelector("#noticeCancelButton"),
+ noticeJunctionLabel: document.querySelector("#noticeJunctionLabel"),
+ noticeText: document.querySelector("#noticeText"),
+ raiseLookoutButton: document.querySelector("#raiseLookoutButton"),
+ lookoutMeta: document.querySelector("#lookoutMeta"),
+ witnessDialog: document.querySelector("#witnessDialog"),
+ witnessCloseButton: document.querySelector("#witnessCloseButton"),
+ witnessName: document.querySelector("#witnessName"),
+ witnessProfile: document.querySelector("#witnessProfile"),
+ witnessSummary: document.querySelector("#witnessSummary"),
+ witnessTranscript: document.querySelector("#witnessTranscript"),
+ witnessConnection: document.querySelector("#witnessConnection"),
+ witnessMessage: document.querySelector("#witnessMessage"),
+ sendWitnessMessage: document.querySelector("#sendWitnessMessage"),
+ autoSpeechButton: document.querySelector("#autoSpeechButton"),
+ pushToTalkButton: document.querySelector("#pushToTalkButton"),
+ stopAudioButton: document.querySelector("#stopAudioButton"),
+ micLevel: document.querySelector("#micLevel"),
+ storyDialog: document.querySelector("#storyDialog"),
+ storyCloseButton: document.querySelector("#storyCloseButton"),
+ storyTimeline: document.querySelector("#storyTimeline"),
+ storyFooter: document.querySelector("#storyFooter"),
+ caseIntroDialog: document.querySelector("#caseIntroDialog"),
+ caseIntroTitle: document.querySelector("#caseIntroTitle"),
+ caseIntroKicker: document.querySelector("#caseIntroKicker"),
+ caseIntroCrime: document.querySelector("#caseIntroCrime"),
+ caseIntroNarrative: document.querySelector("#caseIntroNarrative"),
+ caseIntroStolen: document.querySelector("#caseIntroStolen"),
+ caseIntroVictim: document.querySelector("#caseIntroVictim"),
+ caseIntroAlias: document.querySelector("#caseIntroAlias"),
+ caseIntroDescription: document.querySelector("#caseIntroDescription"),
+ caseIntroImage: document.querySelector("#caseIntroImage"),
+ caseIntroSightings: document.querySelector("#caseIntroSightings"),
+ beginInvestigationButton: document.querySelector("#beginInvestigationButton"),
+ setupOverlay: document.querySelector("#setupOverlay"),
+ setupTitle: document.querySelector("#setupTitle"),
+ setupMessage: document.querySelector("#setupMessage"),
+ setupProgress: document.querySelector("#setupProgress"),
+ setupProgressText: document.querySelector("#setupProgressText"),
+ setupStartButton: document.querySelector("#setupStartButton"),
+ setupSettingsButton: document.querySelector("#setupSettingsButton"),
+ setupPicker: document.querySelector("#setupPicker"),
+ setupStorageHint: document.querySelector("#setupStorageHint"),
+ pickerQuantization: document.querySelector("#pickerQuantization"),
+ pickerDevice: document.querySelector("#pickerDevice"),
+ pickerGpuLayers: document.querySelector("#pickerGpuLayers"),
+ pickerContext: document.querySelector("#pickerContext"),
+ pickerDeviceHint: document.querySelector("#pickerDeviceHint"),
+ pickerQuantHint: document.querySelector("#pickerQuantHint"),
+ gpuDeviceSetting: document.querySelector("#gpuDeviceSetting"),
+ witnessChatTtsSetting: document.querySelector("#witnessChatTtsSetting"),
+ helpButton: document.querySelector("#helpButton"),
+ tutorialDialog: document.querySelector("#tutorialDialog"),
+ tutorialTag: document.querySelector("#tutorialTag"),
+ tutorialHeading: document.querySelector("#tutorialHeading"),
+ tutorialText: document.querySelector("#tutorialText"),
+ tutorialImage: document.querySelector("#tutorialImage"),
+ tutorialDots: document.querySelector("#tutorialDots"),
+ tutorialCounter: document.querySelector("#tutorialCounter"),
+ tutorialBack: document.querySelector("#tutorialBack"),
+ tutorialNext: document.querySelector("#tutorialNext"),
+ tutorialSkip: document.querySelector("#tutorialSkip"),
+};
+
+const state = {
+ gameId: null,
+ layer: "normal",
+ map: { layers: [], junctions: [] },
+ selected: [],
+ focused: null,
+ witnesses: [],
+ witnessCards: [],
+ previousStatements: [],
+ placedTactics: [],
+ tacticCounts: emptyCounts(),
+ sound: true,
+ popup: null,
+ pointerDrag: null,
+ mapView: { zoom: 1.45, x: 0, y: 0, initialized: false },
+ mapPan: null,
+ suppressMapClick: false,
+ settings: null,
+ appScale: 1,
+ game: null,
+ notesDirty: false,
+ notesTimer: null,
+ activeWitness: null,
+ witnessSocket: null,
+ mediaStream: null,
+ captureContext: null,
+ captureNode: null,
+ speechMode: null,
+ pushRecording: false,
+ pushDrainUntil: 0,
+ playbackContext: null,
+ playbackSources: [],
+ playbackTime: 0,
+ setup: null,
+ setupTimer: null,
+ runtimeOptions: null,
+ pickerHydrated: false,
+ activeIntroGameId: null,
+ mapVisibility: { witnesses: true, tactics: true, focus: true },
+ tutorialIndex: 0,
+};
+
+function emptyCounts() {
+ const limits = Object.fromEntries(Object.keys(TACTICS).map((key) => [key, 0]));
+ return {
+ limits,
+ placed: { ...limits },
+ remaining: { ...limits },
+ total_limit: 12,
+ total_remaining: 12,
+ };
+}
+
+function api(path, payload = {}) {
+ return fetch(`/api/${path}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ }).then(async (response) => {
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: response.statusText }));
+ throw new Error(error.detail || response.statusText);
+ }
+ return response.json();
+ });
+}
+
+function payload(extra = {}) {
+ return {
+ game_id: state.gameId,
+ selected_junctions: state.selected,
+ focused_junction: state.focused,
+ ...extra,
+ };
+}
+
+async function boot() {
+ adjustAppScale();
+ restoreEditableTitle();
+ bindEvents();
+ const requestedGameId = new URLSearchParams(window.location.search).get("game_id");
+ const snapshotUrl = requestedGameId
+ ? `/api/snapshot?game_id=${encodeURIComponent(requestedGameId)}`
+ : "/api/snapshot";
+ const snapshot = await fetch(snapshotUrl).then((response) => response.json());
+ applySnapshot(snapshot, false);
+ showOpeningForFreshCase(snapshot);
+ openTutorial(false);
+ if (!state.gameId) flash("Preparing the local AI runtime...", "map_select", false);
+ renderLegend();
+ ensureLocalAI();
+}
+
+function adjustAppScale() {
+ const designWidth = 1672;
+ const designHeight = 940;
+ state.appScale = Math.min(window.innerWidth / designWidth, window.innerHeight / designHeight);
+ document.documentElement.style.setProperty("--app-scale", String(state.appScale));
+}
+
+function bindEvents() {
+ bindEditableTitle(els.gameTitle, "phantomGridTitle");
+ bindEditableTitle(els.gameSubtitle, "phantomGridSubtitle");
+ els.newCaseButton.addEventListener("click", () => openNewCase(true));
+ els.stopGameButton.addEventListener("click", () => finishGame("stopped"));
+ els.restartGameButton.addEventListener("click", () => restartGame());
+ els.settingsButton.addEventListener("click", openSettings);
+ els.advanceButton.addEventListener("click", async () => {
+ if (state.busy) return;
+ if (!state.gameId) return openNewCase(true);
+ if (!beginTurnProcessing()) return;
+ try {
+ const snapshot = await api("advance_turn", payload());
+ playSound("turn_advance");
+ if (snapshot?.sound && snapshot.sound !== "turn_advance") playSound(snapshot.sound);
+ applySnapshot(snapshot, false);
+ } finally {
+ endTurnProcessing();
+ }
+ });
+ els.raiseLookoutButton.addEventListener("click", publishNotice);
+ els.noticeCloseButton.addEventListener("click", () => els.noticeDialog.close());
+ els.noticeCancelButton.addEventListener("click", () => els.noticeDialog.close());
+ els.notesText.addEventListener("input", scheduleNotesSave);
+
+ els.mapWrap.addEventListener("click", handleMapClick);
+ els.mapWrap.addEventListener("pointerdown", startMapPan);
+ els.mapWrap.addEventListener("dragover", (event) => event.preventDefault());
+ els.mapWrap.addEventListener("drop", handleMapDrop);
+ els.mapWrap.addEventListener("wheel", handleMapWheel, { passive: false });
+ els.zoomOutButton.addEventListener("click", () => zoomBy(0.86));
+ els.zoomInButton.addEventListener("click", () => zoomBy(1.16));
+ els.zoomResetButton.addEventListener("click", () => resetMapView(true));
+ els.toggleWitnessesButton.addEventListener("click", () => toggleMapVisibility("witnesses"));
+ els.toggleTacticsButton.addEventListener("click", () => toggleMapVisibility("tactics"));
+ els.toggleFocusButton.addEventListener("click", () => toggleMapVisibility("focus"));
+ els.witnessModeButton.addEventListener("click", enableWitnessMode);
+ els.tacticTray.addEventListener("pointerdown", startTrayPointerDrag);
+ els.tacticLayer.addEventListener("pointerdown", startPlacedPointerDrag);
+ els.witnessLayer.addEventListener("click", handleWitnessClick);
+ els.tacticLayer.addEventListener("click", handleTacticClick);
+ els.detailPopup.addEventListener("click", handlePopupClick);
+ document.addEventListener("click", (event) => {
+ if (!event.target.closest(".detail-popup, .map-token, .witness-token")) {
+ closePopup();
+ }
+ });
+ document.addEventListener("dragover", (event) => event.preventDefault());
+ document.addEventListener("drop", handleDocumentDrop);
+ window.addEventListener("pointermove", movePointerDrag);
+ window.addEventListener("pointerup", endPointerDrag);
+ window.addEventListener("pointercancel", cancelPointerDrag);
+ window.addEventListener("pointermove", moveMapPan);
+ window.addEventListener("pointerup", endMapPan);
+ window.addEventListener("pointercancel", cancelMapPan);
+ window.addEventListener("resize", () => {
+ adjustAppScale();
+ clampMapView();
+ renderMapView();
+ renderMapOverlays();
+ });
+ els.mapImage.addEventListener("load", () => resetMapView(false));
+ els.settingsCloseButton.addEventListener("click", () => els.settingsDialog.close());
+ els.settingsSaveButton.addEventListener("click", saveSettings);
+ els.providerSetting.addEventListener("change", renderBackendFields);
+ els.llamaStartButton.addEventListener("click", () => runLlamaAction("start"));
+ els.llamaRestartButton.addEventListener("click", () => runLlamaAction("restart"));
+ els.llamaStopButton.addEventListener("click", () => runLlamaAction("stop"));
+ els.setupStartButton.addEventListener("click", handleSetupStart);
+ els.setupSettingsButton.addEventListener("click", openSettings);
+ els.witnessCloseButton.addEventListener("click", closeWitnessInterview);
+ els.sendWitnessMessage.addEventListener("click", sendWitnessText);
+ els.witnessMessage.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); sendWitnessText(); }
+ });
+ els.autoSpeechButton.addEventListener("click", toggleAutoSpeech);
+ els.pushToTalkButton.addEventListener("pointerdown", startPushToTalk);
+ els.pushToTalkButton.addEventListener("pointerup", stopPushToTalk);
+ els.pushToTalkButton.addEventListener("pointercancel", stopPushToTalk);
+ window.addEventListener("pointerup", () => { if (state.pushRecording) stopPushToTalk(); });
+ els.stopAudioButton.addEventListener("click", stopPlayback);
+ els.storyCloseButton.addEventListener("click", () => els.storyDialog.close());
+ els.beginInvestigationButton.addEventListener("click", dismissCaseIntroduction);
+ els.helpButton.addEventListener("click", () => openTutorial(true));
+ els.tutorialNext.addEventListener("click", () => advanceTutorial(1));
+ els.tutorialBack.addEventListener("click", () => advanceTutorial(-1));
+ els.tutorialSkip.addEventListener("click", closeTutorial);
+ els.tutorialDots.addEventListener("click", (event) => {
+ const dot = event.target.closest("[data-slide]");
+ if (dot) gotoTutorialSlide(Number(dot.dataset.slide));
+ });
+ els.tutorialDialog.addEventListener("keydown", (event) => {
+ if (event.key === "ArrowRight") advanceTutorial(1);
+ else if (event.key === "ArrowLeft") advanceTutorial(-1);
+ });
+ els.tutorialDialog.addEventListener("close", () => {
+ localStorage.setItem(TUTORIAL_SEEN_KEY, "1");
+ });
+}
+
+async function ensureLocalAI() {
+ clearTimeout(state.setupTimer);
+ try {
+ const setup = await fetch("/api/setup/status").then((response) => response.json());
+ if (!state.runtimeOptions) await loadRuntimeOptions();
+ renderSetup(setup);
+ if (setup.service_ready) return;
+ // Files-ready but service not running: bring it up automatically with the
+ // settings the user already picked. We do NOT auto-start the heavy download
+ // — that waits for the user to confirm picker choices.
+ if (setup.files_ready && !setup.installing) {
+ const restarted = await fetch("/api/setup/start", { method: "POST" }).then((response) => response.json());
+ renderSetup(restarted);
+ }
+ const next = setup.installing || setup.files_ready ? 2000 : 4000;
+ state.setupTimer = setTimeout(ensureLocalAI, next);
+ } catch (error) {
+ renderSetup({ state: "error", message: error.message || "Setup status could not be read.", progress: 0 });
+ }
+}
+
+async function loadRuntimeOptions() {
+ try {
+ const data = await fetch("/api/runtime/options").then((response) => response.json());
+ state.runtimeOptions = data;
+ populatePicker(data);
+ } catch (error) {
+ els.pickerDeviceHint.textContent = error.message || "Could not detect runtime options; using defaults.";
+ }
+}
+
+function populatePicker(options) {
+ if (!options || state.pickerHydrated) return;
+ const current = options.current || {};
+ fillSelect(els.pickerQuantization, options.quantizations || [], (item) => ({
+ value: item.id, label: item.label, selected: item.id === current.minicpm_quantization,
+ }));
+ fillSelect(els.pickerDevice, options.devices || [], (item) => ({
+ value: item.id, label: item.label, selected: item.id === current.minicpm_gpu_device,
+ }));
+ fillSelect(els.pickerGpuLayers, options.gpu_layer_presets || [], (item) => ({
+ value: item.id, label: item.label, selected: String(item.id) === String(current.llamacpp_gpu_layers),
+ }));
+ fillSelect(els.pickerContext, options.context_length_presets || [], (item) => ({
+ value: String(item.id), label: item.label, selected: Number(item.id) === Number(current.llamacpp_context_length),
+ }));
+ // Mirror the GPU-device dropdown in the settings dialog using the same list.
+ fillSelect(els.gpuDeviceSetting, options.devices || [], (item) => ({
+ value: item.id, label: item.label, selected: item.id === current.minicpm_gpu_device,
+ }));
+ if (els.setupStorageHint) {
+ els.setupStorageHint.textContent = `Managed files will be stored in ${options.runtime_root || "this project's runtime folder"}. ${options.free_disk_gb ?? "Unknown"} GB free.`;
+ }
+ state.pickerHydrated = true;
+}
+
+function fillSelect(select, items, mapper) {
+ if (!select) return;
+ select.innerHTML = "";
+ items.forEach((item) => {
+ const { value, label, selected } = mapper(item);
+ const option = document.createElement("option");
+ option.value = String(value);
+ option.textContent = label;
+ if (selected) option.selected = true;
+ select.append(option);
+ });
+}
+
+function renderSetup(setup) {
+ state.setup = setup;
+ const ready = Boolean(setup.service_ready);
+ const installing = Boolean(setup.installing) || (setup.files_ready && !setup.service_ready) || setup.state === "running";
+ const errored = setup.state === "error";
+ // Show the picker only at the moment when nothing is downloading or running.
+ // Once setup is in flight, hide it so the progress UI takes over.
+ const showPicker = !ready && !installing && !errored && Boolean(state.runtimeOptions);
+
+ els.setupOverlay.classList.toggle("ready", ready);
+ els.setupOverlay.hidden = Boolean(state.gameId);
+ els.setupPicker.hidden = !showPicker;
+ els.setupProgress.hidden = showPicker;
+ els.setupProgressText.hidden = showPicker;
+ els.setupProgress.value = Number(setup.progress || 0);
+ els.setupMessage.textContent = setup.message || "Preparing the local AI runtime...";
+ els.setupProgressText.textContent = ready ? "Everything is ready" : `${Math.round(setup.progress || 0)}% - ${setup.stage || "setup"}`;
+
+ if (ready) {
+ els.setupTitle.textContent = "The Investigation Desk Is Ready";
+ els.setupStartButton.textContent = "Start Game";
+ els.setupStartButton.disabled = false;
+ return;
+ }
+ if (errored) {
+ els.setupTitle.textContent = "Local AI Setup Needs Attention";
+ els.setupStartButton.textContent = "Retry Setup";
+ els.setupStartButton.disabled = false;
+ return;
+ }
+ if (showPicker) {
+ els.setupTitle.textContent = "Set Up Your Local AI";
+ els.setupStartButton.textContent = "Download & Install With These Settings";
+ els.setupStartButton.disabled = false;
+ return;
+ }
+ els.setupTitle.textContent = setup.files_ready ? "Starting Local AI" : "Preparing Your Investigation Desk";
+ els.setupStartButton.textContent = setup.files_ready ? "Loading Model..." : "Downloading and Installing...";
+ els.setupStartButton.disabled = true;
+}
+
+function pickerPayload() {
+ return {
+ llm_provider: "minicpm_omni",
+ minicpm_quantization: els.pickerQuantization?.value || undefined,
+ minicpm_gpu_device: els.pickerDevice?.value || undefined,
+ llamacpp_gpu_layers: els.pickerGpuLayers?.value || undefined,
+ llamacpp_context_length: els.pickerContext?.value ? Number(els.pickerContext.value) : undefined,
+ };
+}
+
+async function handleSetupStart() {
+ if (state.setup?.service_ready) {
+ els.setupStartButton.disabled = true;
+ els.setupStartButton.textContent = "Opening Case...";
+ await openNewCase(true);
+ if (state.gameId) els.setupOverlay.hidden = true;
+ return;
+ }
+ els.setupStartButton.disabled = true;
+ els.setupStartButton.textContent = "Starting...";
+ // If the picker is visible, post the chosen options. Otherwise this is a
+ // retry or service-restart — backend uses whatever's already in .env.
+ const payload = state.runtimeOptions ? pickerPayload() : { llm_provider: "minicpm_omni" };
+ try {
+ const setup = await api("setup/start", payload);
+ renderSetup(setup);
+ } catch (error) {
+ renderSetup({
+ state: "missing",
+ message: error.message || "Could not start setup.",
+ progress: 0,
+ files_ready: false,
+ service_ready: false,
+ installing: false,
+ });
+ return;
+ }
+ ensureLocalAI();
+}
+
+function restoreEditableTitle() {
+ const savedTitle = localStorage.getItem("phantomGridTitle");
+ const savedSubtitle = localStorage.getItem("phantomGridSubtitle");
+ if (savedTitle) els.gameTitle.textContent = savedTitle;
+ if (savedSubtitle) els.gameSubtitle.textContent = savedSubtitle;
+}
+
+function bindEditableTitle(element, storageKey) {
+ element.addEventListener("input", () => {
+ localStorage.setItem(storageKey, element.textContent.trim());
+ });
+ element.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ element.blur();
+ }
+ });
+ element.addEventListener("blur", () => {
+ if (!element.textContent.trim()) {
+ element.textContent = storageKey === "phantomGridTitle" ? "Phantom Grid" : "Catch John Doe before he vanishes again!";
+ }
+ localStorage.setItem(storageKey, element.textContent.trim());
+ });
+}
+
+async function openSettings() {
+ try {
+ const data = await fetch("/api/settings").then((response) => response.json());
+ state.settings = data.settings;
+ populateSettings(data);
+ if (!els.settingsDialog.open) {
+ els.settingsDialog.showModal();
+ }
+ } catch (error) {
+ flash(error.message || "Could not load settings.", "map_select");
+ }
+}
+
+function populateSettings(data) {
+ const settings = data.settings || {};
+ els.soundSetting.value = state.sound ? "on" : "off";
+ els.difficultySetting.value = settings.difficulty || "normal";
+ els.providerSetting.value = settings.llm_provider || "llama_cpp_server";
+ els.modelPathSetting.value = settings.llamacpp_model_path || "";
+ els.serverBinSetting.value = settings.llamacpp_server_bin || "";
+ els.baseUrlSetting.value = settings.llamacpp_base_url || "http://127.0.0.1:8080/v1";
+ els.llmModelSetting.value = settings.llm_model || "";
+ renderBackendFields();
+ els.gatewayUrlSetting.value = settings.omni_gateway_url || "http://127.0.0.1:8006";
+ els.launcherPathSetting.value = settings.omni_launcher_path || "";
+ els.comniCheckoutSetting.value = settings.comni_checkout_path || "";
+ els.omniRootSetting.value = settings.llamacpp_omni_root || "";
+ els.modelDirSetting.value = settings.minicpm_model_dir || "";
+ els.contextLengthSetting.value = String(settings.llamacpp_context_length || 8192);
+ els.gpuLayersSetting.value = settings.llamacpp_gpu_layers || "auto";
+ els.voiceDirSetting.value = settings.witness_voice_dir || "";
+ if (state.runtimeOptions?.devices && !els.gpuDeviceSetting.options.length) {
+ fillSelect(els.gpuDeviceSetting, state.runtimeOptions.devices, (item) => ({
+ value: item.id, label: item.label, selected: item.id === (settings.minicpm_gpu_device || "auto"),
+ }));
+ } else {
+ els.gpuDeviceSetting.value = settings.minicpm_gpu_device || "auto";
+ }
+ els.witnessChatTtsSetting.value = settings.witness_chat_tts === false ? "0" : "1";
+ const models = data.model_scan?.models || [];
+ els.quantizationSetting.innerHTML = models.length ? "" : 'No compatible models found ';
+ models.forEach((model) => {
+ const option = document.createElement("option");
+ option.value = model.filename;
+ option.textContent = `${model.quantization} (${formatBytes(model.size_bytes)})`;
+ option.selected = model.filename === settings.minicpm_quantization;
+ els.quantizationSetting.append(option);
+ });
+ renderLlamaStatus(data.llama || data.omni, settings);
+}
+
+function settingsPayload() {
+ return {
+ llm_provider: els.providerSetting.value,
+ llamacpp_model_path: els.modelPathSetting.value,
+ llamacpp_server_bin: els.serverBinSetting.value,
+ llamacpp_base_url: els.baseUrlSetting.value,
+ llm_model: els.llmModelSetting.value,
+ difficulty: els.difficultySetting.value,
+ omni_gateway_url: els.gatewayUrlSetting.value,
+ omni_launcher_path: els.launcherPathSetting.value,
+ comni_checkout_path: els.comniCheckoutSetting.value,
+ llamacpp_omni_root: els.omniRootSetting.value,
+ minicpm_model_dir: els.modelDirSetting.value,
+ minicpm_quantization: els.quantizationSetting.value,
+ llamacpp_context_length: Number(els.contextLengthSetting.value),
+ llamacpp_gpu_layers: els.gpuLayersSetting.value,
+ minicpm_gpu_device: els.gpuDeviceSetting.value,
+ witness_chat_tts: els.witnessChatTtsSetting.value === "1",
+ witness_voice_dir: els.voiceDirSetting.value,
+ };
+}
+
+function renderBackendFields() {
+ const managed = els.providerSetting.value === "llama_cpp_server";
+ const external = els.providerSetting.value === "external_llama_cpp_server";
+ els.customModelSettings.hidden = !managed;
+ els.llamaConnectionSettings.hidden = !(managed || external);
+ els.externalServerHint.hidden = !external;
+ els.llmModelSetting.disabled = managed;
+ els.llamaStartButton.disabled = external;
+ els.llamaRestartButton.disabled = external;
+ els.llamaStopButton.disabled = external;
+}
+
+async function saveSettings() {
+ try {
+ state.sound = els.soundSetting.value === "on";
+ const data = await api("settings", settingsPayload());
+ state.settings = data.settings;
+ populateSettings(data);
+ flash("Settings saved. Difficulty applies to new cases.", "map_select");
+ } catch (error) {
+ flash(error.message || "Could not save settings.", "map_select");
+ }
+}
+
+async function runLlamaAction(action) {
+ try {
+ state.sound = els.soundSetting.value === "on";
+ const response = await fetch(`/api/llama/${action}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(settingsPayload()),
+ });
+ const data = await response.json();
+ if (data.settings) {
+ state.settings = data.settings;
+ }
+ renderLlamaStatus(data.llama || data.omni, data.settings || state.settings || {});
+ flash(data.event || "AI backend status updated.", data.ok ? "blockade_set" : "map_select");
+ } catch (error) {
+ flash(error.message || "Could not control the AI backend.", "map_select");
+ }
+}
+
+function renderLlamaStatus(llama, settings) {
+ const custom = settings.llm_provider === "llama_cpp_server";
+ const external = settings.llm_provider === "external_llama_cpp_server";
+ const backend = custom || external ? (settings.llm_model || "llama.cpp") : "OpenBMB MiniCPM-o";
+ const launcher = external
+ ? "user-managed server"
+ : custom
+ ? (settings.llamacpp_model_exists && settings.llamacpp_server_bin_exists ? "model and server paths ok" : "model or server path missing")
+ : (settings.omni_launcher_exists ? "launcher path ok" : "launcher path missing");
+ const reach = llama?.ready ? "ready" : llama?.reachable ? "reachable, not ready" : "not reachable";
+ const pid = llama?.pid ? ` PID ${llama.pid}` : "";
+ let detail = typeof llama?.detail === "string" ? llama.detail : "";
+ if (!detail && (custom || external) && Array.isArray(llama?.detail?.data)) {
+ detail = `${llama.detail.data.length} model${llama.detail.data.length === 1 ? "" : "s"} loaded`;
+ } else if (!detail && llama?.detail?.workers) {
+ const workers = llama.detail.workers;
+ detail = `${workers.idle_workers || 0} of ${workers.total_workers || 0} workers idle`;
+ }
+ els.llamaStatusText.textContent = `${backend}: ${reach}${pid}. ${launcher}. Context ${settings.llamacpp_context_length || 8192}; GPU layers ${settings.llamacpp_gpu_layers || "auto"}. ${detail}`;
+}
+
+async function openNewCase(makeNoise) {
+ if (state.busy) return;
+ closePopup();
+ if (!beginNewCaseProcessing()) return;
+ try {
+ const snapshot = await api("new_case", {});
+ applySnapshot(snapshot, makeNoise);
+ const openingJunction = snapshot.game?.last_seen?.junction_id || DEFAULT_FOCUSED_JUNCTION;
+ state.selected = [openingJunction];
+ state.focused = openingJunction;
+ applySnapshot(await api("select_junctions", payload()), makeNoise);
+ showCaseIntroduction(snapshot.case_introduction, snapshot.game?.initial_description, snapshot.game?.game_id, true);
+ } catch (error) {
+ flash(error.message || "MiniCPM-o must be ready before a case can start.", "map_select");
+ await openSettings();
+ } finally {
+ endNewCaseProcessing();
+ }
+}
+
+function showOpeningForFreshCase(snapshot) {
+ if (!snapshot?.game || snapshot.game.turn !== 1 || snapshot.game.result || snapshot.game.phase === "complete") return;
+ showCaseIntroduction(
+ snapshot.case_introduction,
+ snapshot.game.initial_description,
+ snapshot.game.game_id,
+ );
+}
+
+function introSeenKey(gameId) {
+ return `phantomGridIntroSeen:${gameId}`;
+}
+
+function showCaseIntroduction(intro, description, gameId, force = false) {
+ if (!intro || !els.caseIntroDialog) return;
+ if (!force && gameId && sessionStorage.getItem(introSeenKey(gameId)) === "1") return;
+ state.activeIntroGameId = gameId || state.gameId;
+ els.caseIntroTitle.textContent = intro.case_title || "A New Case";
+ els.caseIntroKicker.textContent = intro.kicker || "A thief has vanished into London.";
+ els.caseIntroCrime.textContent = titleCase(intro.crime || "A daring theft");
+ els.caseIntroNarrative.textContent = intro.narrative || "The trail is already growing cold.";
+ els.caseIntroStolen.textContent = intro.stolen_item || "Unknown valuables";
+ els.caseIntroVictim.textContent = intro.victim || "Name withheld";
+ els.caseIntroAlias.textContent = intro.culprit_alias || "John Doe";
+ els.caseIntroDescription.textContent = description || "Description unavailable.";
+ if (intro.suspect_image && els.caseIntroImage) {
+ els.caseIntroImage.src = versionedSuspectImage(intro.suspect_image, gameId);
+ }
+ els.caseIntroSightings.innerHTML = (intro.last_seen || []).map((sighting, index) => `
+
+ ${String(index + 1).padStart(2, "0")}
+ ${escapeHtml(sighting.label || "Report")} ${escapeHtml(sighting.location || `Junction ${sighting.junction_id}`)} ${escapeHtml(sighting.detail || "")}
+
+ `).join("");
+ if (!els.caseIntroDialog.open) els.caseIntroDialog.showModal();
+ const shell = els.caseIntroDialog.querySelector(".case-intro-shell");
+ shell.scrollTop = 0;
+ shell.focus({ preventScroll: true });
+}
+
+function dismissCaseIntroduction() {
+ if (state.activeIntroGameId) sessionStorage.setItem(introSeenKey(state.activeIntroGameId), "1");
+ state.activeIntroGameId = null;
+ els.caseIntroDialog.close();
+}
+
+const TUTORIAL_SEEN_KEY = "phantomGridTutorialSeen:v1";
+const TUTORIAL_IMG = "/static/assets/tutorial/";
+const TUTORIAL_SLIDES = [
+ {
+ tag: "Your Mission",
+ heading: "Catch The Phantom",
+ image: "01_board_overview.png",
+ points: [
+ "You are the Commissioner of the Lantern Watch Bureau.",
+ "A thief is slipping across London's transport grid — one hidden move each turn.",
+ "Corner them before the turn counter (top-right) runs out.",
+ "Everything is on one board: the suspect at left, the map in the centre, your notes at right.",
+ ],
+ },
+ {
+ tag: "Step 1",
+ heading: "Read The Case Briefing",
+ image: "02_briefing.png",
+ points: [
+ "Every case opens with a dossier : the crime, the suspect, and their last-known sightings.",
+ "The description — coat, hat, what they carry — is your key to spotting real witness reports.",
+ "Note the Last Seen junctions. That is where the trail begins.",
+ ],
+ },
+ {
+ tag: "Step 2",
+ heading: "Work The Map & Transport",
+ image: "03_map_layers.png",
+ points: [
+ "Click a junction to focus it and reveal its legal moves.",
+ "Switch layers — Normal, Taxi, Bus, Subway — to see which routes connect where.",
+ "The suspect can only travel these lines, so cutting the right mode matters.",
+ "Drag to pan; use +/- or the mouse wheel to zoom.",
+ ],
+ },
+ {
+ tag: "Step 3",
+ heading: "Gather Witnesses",
+ image: "08_witnesses_map.png",
+ points: [
+ "Pins mark people who saw someone. Toggle Witness Mode to focus on them.",
+ "A viewed report looks different from one you have not opened yet.",
+ "Reports can be true sightings or false alarms — weigh each against the description.",
+ ],
+ },
+ {
+ tag: "Step 4",
+ heading: "Interview A Witness",
+ image: "07_witness_interview.png",
+ points: [
+ "Click a witness pin to open the interview .",
+ "Read their statement, then ask about colour, direction, time, or what they carried .",
+ "You can type or use your voice. Memories fade over turns, so ask early.",
+ ],
+ },
+ {
+ tag: "Step 5",
+ heading: "Issue Public Notices",
+ image: "06_notice.png",
+ points: [
+ "Post a public appeal to bring more witnesses forward near a junction.",
+ "The wording matters — a precise description surfaces the right people.",
+ "A tight notice in the right area can flush out a fresh lead.",
+ ],
+ },
+ {
+ tag: "Step 6",
+ heading: "Deploy Tactics & Take Your Turn",
+ image: "04_tactics_tray.png",
+ points: [
+ "Drag tactics onto junctions: Roadblock and Junction Lockdown seal routes.",
+ "Patrol Units deter and file reports; a Search Team wins instantly if the suspect is there.",
+ "A Lookout Board boosts notices. Units and searches are limited each turn.",
+ "When you are set, press Advance Turn — the suspect moves, and the hunt goes on. Catch them to win.",
+ ],
+ },
+];
+
+function renderTutorialSlide() {
+ const index = Math.max(0, Math.min(state.tutorialIndex, TUTORIAL_SLIDES.length - 1));
+ state.tutorialIndex = index;
+ const slide = TUTORIAL_SLIDES[index];
+ els.tutorialTag.textContent = slide.tag;
+ els.tutorialHeading.textContent = slide.heading;
+ els.tutorialImage.src = `${TUTORIAL_IMG}${slide.image}?v=1`;
+ els.tutorialImage.alt = slide.heading;
+ els.tutorialText.innerHTML = slide.points.map((point) => `${point} `).join("");
+ els.tutorialDots.innerHTML = TUTORIAL_SLIDES.map((_, i) =>
+ ` `
+ ).join("");
+ els.tutorialCounter.textContent = `${index + 1} / ${TUTORIAL_SLIDES.length}`;
+ els.tutorialBack.disabled = index === 0;
+ const last = index === TUTORIAL_SLIDES.length - 1;
+ els.tutorialNext.textContent = last ? "Start Playing" : "Next";
+ els.tutorialSkip.hidden = last;
+ els.tutorialDialog.querySelector(".tutorial-shell").scrollTop = 0;
+}
+
+function openTutorial(force = false) {
+ if (!els.tutorialDialog) return;
+ if (!force && localStorage.getItem(TUTORIAL_SEEN_KEY) === "1") return;
+ state.tutorialIndex = 0;
+ renderTutorialSlide();
+ if (!els.tutorialDialog.open) els.tutorialDialog.showModal();
+ els.tutorialDialog.querySelector(".tutorial-shell").focus({ preventScroll: true });
+}
+
+function closeTutorial() {
+ localStorage.setItem(TUTORIAL_SEEN_KEY, "1");
+ if (els.tutorialDialog.open) els.tutorialDialog.close();
+}
+
+function gotoTutorialSlide(index) {
+ state.tutorialIndex = index;
+ renderTutorialSlide();
+}
+
+function advanceTutorial(delta) {
+ const next = state.tutorialIndex + delta;
+ if (next >= TUTORIAL_SLIDES.length) {
+ closeTutorial();
+ return;
+ }
+ gotoTutorialSlide(Math.max(0, next));
+}
+
+function titleCase(value) {
+ return String(value).replace(/\b\w/g, (letter) => letter.toUpperCase());
+}
+
+function applySnapshot(snapshot, makeNoise = true) {
+ if (!snapshot || !snapshot.ok) return;
+ state.gameId = snapshot.game?.game_id || state.gameId;
+ if (state.gameId) {
+ const url = new URL(window.location.href);
+ if (url.searchParams.get("game_id") !== state.gameId) {
+ url.searchParams.set("game_id", state.gameId);
+ window.history.replaceState({}, "", url);
+ }
+ }
+ state.map = snapshot.map || state.map;
+ state.selected = snapshot.selection?.junctions || state.selected;
+ state.focused = snapshot.selection?.focused ?? state.focused;
+ state.witnesses = snapshot.witness_locations || [];
+ state.witnessCards = snapshot.witness_cards || [];
+ state.previousStatements = snapshot.previous_statements || [];
+ state.placedTactics = snapshot.placed_tactics || [];
+ state.tacticCounts = snapshot.tactic_counts || state.tacticCounts;
+ state.game = snapshot.game || state.game;
+ if (snapshot.case_introduction?.culprit_alias) els.wantedAlias.textContent = snapshot.case_introduction.culprit_alias;
+ if (!state.notesDirty && typeof snapshot.notes === "string") els.notesText.value = snapshot.notes;
+
+ renderGame(snapshot.game);
+ renderTacticTray();
+ renderLayers();
+ renderMap();
+ renderMapOverlays();
+ renderLookout(snapshot.lookout);
+ renderStatements();
+ renderActiveUnits();
+
+ if (snapshot.notice_prompt?.open) openNoticeDialog(snapshot.notice_prompt);
+ if (snapshot.game?.result && snapshot.story_available) loadStoryReveal();
+
+ if (snapshot.event) {
+ flash(snapshot.event, snapshot.sound, makeNoise);
+ }
+}
+
+function renderGame(game) {
+ if (!game) {
+ els.caseClock.textContent = "-";
+ els.turnPhase.textContent = "Evening";
+ els.advanceButton.disabled = true;
+ els.stopGameButton.disabled = true;
+ return;
+ }
+ const complete = Boolean(game.result || game.phase === "complete");
+ els.caseClock.textContent = `${game.turn} / ${game.max_turns}`;
+ els.turnPhase.textContent = turnPhase(game.turn);
+ els.wantedDescription.textContent = game.initial_description || els.wantedDescription.textContent;
+ els.wantedLastSeen.textContent = game.last_seen?.location || (game.last_seen?.junction_id ? `Junction ${game.last_seen.junction_id}` : "Awaiting confirmed location");
+ if (game.suspect_image && els.suspectImage) {
+ els.suspectImage.src = versionedSuspectImage(game.suspect_image, game.game_id);
+ }
+ els.advanceButton.disabled = complete;
+ els.stopGameButton.disabled = complete;
+}
+
+function versionedSuspectImage(url, gameId) {
+ const separator = String(url).includes("?") ? "&" : "?";
+ return `${url}${separator}case=${encodeURIComponent(gameId || "current")}`;
+}
+
+function renderTacticTray() {
+ els.tacticTray.innerHTML = "";
+ const complete = Boolean(state.game?.result || state.game?.phase === "complete");
+ for (const [type, tactic] of Object.entries(TACTICS)) {
+ const remaining = complete ? 0 : (state.tacticCounts.remaining?.[type] ?? 0);
+ const limit = state.tacticCounts.limits?.[type] ?? 0;
+ const card = document.createElement("button");
+ card.type = "button";
+ card.className = "tactic-card";
+ card.draggable = remaining > 0;
+ card.disabled = remaining <= 0;
+ card.dataset.tacticType = type;
+ card.setAttribute("aria-label", `${tactic.label}, ${remaining} of ${limit} remaining. ${tactic.preview}`);
+ card.innerHTML = `
+
+ ${escapeHtml(tactic.label)}
+ ${remaining} / ${limit}
+ ${escapeHtml(tactic.preview)}${remaining} left
+ `;
+ card.addEventListener("dragstart", (event) => {
+ if (remaining <= 0) {
+ event.preventDefault();
+ return;
+ }
+ event.dataTransfer.setData("application/x-tactic-type", type);
+ event.dataTransfer.effectAllowed = "copy";
+ });
+ els.tacticTray.append(card);
+ }
+}
+
+function renderLayers() {
+ const ordered = ["normal", "taxi", "bus", "subway"].filter((layer) => state.map.layers.includes(layer));
+ const key = ordered.join("|");
+ if (els.layerTabs.dataset.ready !== key) {
+ els.layerTabs.dataset.ready = key;
+ els.layerTabs.innerHTML = "";
+ ordered.forEach((layer) => {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.textContent = LAYER_LABELS[layer] || layer;
+ button.dataset.layer = layer;
+ button.addEventListener("click", () => {
+ state.layer = layer;
+ state.mapView.initialized = false;
+ renderLayers();
+ renderMap();
+ playSound("map_select");
+ });
+ els.layerTabs.append(button);
+ });
+ }
+ [...els.layerTabs.children].forEach((button) => {
+ button.classList.toggle("active", button.dataset.layer === state.layer);
+ });
+}
+
+function renderMap() {
+ const nextSrc = `/assets/maps/${state.layer}`;
+ if (!els.mapImage.src.endsWith(nextSrc)) {
+ els.mapImage.src = nextSrc;
+ }
+}
+
+function renderMapView() {
+ els.mapCanvas.style.transform = `translate(${state.mapView.x}px, ${state.mapView.y}px) scale(${state.mapView.zoom})`;
+ els.zoomValue.textContent = `${Math.round(state.mapView.zoom * 100)}%`;
+}
+
+function resetMapView(force) {
+ if (!els.mapImage.naturalWidth) return;
+ if (!force && state.mapView.initialized) {
+ renderMapView();
+ renderMapOverlays();
+ return;
+ }
+ state.mapView.zoom = 1.45;
+ const wrap = { width: els.mapWrap.clientWidth, height: els.mapWrap.clientHeight };
+ const base = imageBaseRect();
+ const offset = currentLayerYOffset();
+ const focus = junctionById(state.focused || DEFAULT_FOCUSED_JUNCTION);
+ const targetX = base.left + ((focus?.x || els.mapImage.naturalWidth / 2) / els.mapImage.naturalWidth) * base.width;
+ const focusY = focus?.y != null ? focus.y + offset : els.mapImage.naturalHeight / 2;
+ const targetY = base.top + (focusY / els.mapImage.naturalHeight) * base.height;
+ state.mapView.x = wrap.width / 2 - targetX * state.mapView.zoom;
+ state.mapView.y = wrap.height / 2 - targetY * state.mapView.zoom;
+ state.mapView.initialized = true;
+ clampMapView();
+ renderMapView();
+ renderMapOverlays();
+}
+
+function zoomBy(factor, clientX = null, clientY = null) {
+ const wrap = els.mapWrap.getBoundingClientRect();
+ const anchorX = clientX == null ? els.mapWrap.clientWidth / 2 : (clientX - wrap.left) / state.appScale;
+ const anchorY = clientY == null ? els.mapWrap.clientHeight / 2 : (clientY - wrap.top) / state.appScale;
+ const oldZoom = state.mapView.zoom;
+ const nextZoom = Math.min(Math.max(oldZoom * factor, 0.85), 6);
+ const worldX = (anchorX - state.mapView.x) / oldZoom;
+ const worldY = (anchorY - state.mapView.y) / oldZoom;
+ state.mapView.zoom = nextZoom;
+ state.mapView.x = anchorX - worldX * nextZoom;
+ state.mapView.y = anchorY - worldY * nextZoom;
+ clampMapView();
+ renderMapView();
+}
+
+function clampMapView() {
+ const wrap = { width: els.mapWrap.clientWidth, height: els.mapWrap.clientHeight };
+ if (!wrap.width || !wrap.height) return;
+ const zoom = state.mapView.zoom;
+ const scaledWidth = wrap.width * zoom;
+ const scaledHeight = wrap.height * zoom;
+ const minX = Math.min(0, wrap.width - scaledWidth);
+ const minY = Math.min(0, wrap.height - scaledHeight);
+ state.mapView.x = Math.min(Math.max(state.mapView.x, minX), 0);
+ state.mapView.y = Math.min(Math.max(state.mapView.y, minY), 0);
+}
+
+function renderMapOverlays() {
+ els.selectionLayer.innerHTML = "";
+ els.witnessLayer.innerHTML = "";
+ els.tacticLayer.innerHTML = "";
+ const tacticCountsByJunction = new Map();
+ state.placedTactics.forEach((placed) => {
+ tacticCountsByJunction.set(placed.junction_id, (tacticCountsByJunction.get(placed.junction_id) || 0) + 1);
+ });
+ const witnessJunctions = new Set(
+ state.mapVisibility.witnesses ? state.witnesses.map((witness) => witness.junction_id) : [],
+ );
+
+ const focused = junctionById(state.focused);
+ if (focused && state.mapVisibility.focus) {
+ const marker = document.createElement("div");
+ marker.className = "focus-marker";
+ placeAtMapPoint(marker, focused.x, focused.y);
+ els.selectionLayer.append(marker);
+ }
+
+ if (state.mapVisibility.witnesses) state.witnesses.forEach((witness) => {
+ const junction = junctionById(witness.junction_id);
+ if (!junction) return;
+ const reports = witness.reports?.length ? witness.reports : [{
+ id: witness.sample_witness_id,
+ viewed: witness.viewed,
+ summary: witness.sample_summary,
+ }];
+ if (reports.length > 1) {
+ const cluster = document.createElement("button");
+ cluster.type = "button";
+ cluster.className = "witness-token witness-cluster-token";
+ cluster.dataset.witnessClusterJunction = String(witness.junction_id);
+ cluster.setAttribute("aria-label", `${reports.length} separate witness reports at Junction ${witness.junction_id}. Open report list.`);
+ cluster.innerHTML = `
+
+ ${reports.length}
+ `;
+ placeAtMapPoint(cluster, junction.x, junction.y);
+ els.witnessLayer.append(cluster);
+ }
+ reports.forEach((report, reportIndex) => {
+ const token = document.createElement("button");
+ const offset = witnessReportOffset(reportIndex, reports.length, tacticCountsByJunction.has(witness.junction_id));
+ token.type = "button";
+ token.className = `witness-token witness-cluster-member ${report.viewed ? "viewed" : "unviewed"}`;
+ token.dataset.junctionId = String(witness.junction_id);
+ token.dataset.witnessId = report.id || "";
+ token.style.setProperty("--token-offset-x", `${offset.x}px`);
+ token.style.setProperty("--token-offset-y", `${offset.y}px`);
+ if (reports.length > 1 || tacticCountsByJunction.has(witness.junction_id)) token.classList.add("co-located");
+ token.setAttribute("aria-label", `${report.viewed ? "Viewed" : "Unviewed"} witness ${reportIndex + 1} of ${reports.length} at Junction ${witness.junction_id}`);
+ token.innerHTML = `
+
+ ${reports.length > 1 ? `${reportIndex + 1} ` : ""}
+ `;
+ placeAtMapPoint(token, junction.x, junction.y);
+ els.witnessLayer.append(token);
+ });
+ });
+
+ const renderedTacticsByJunction = new Map();
+ if (state.mapVisibility.tactics) state.placedTactics.forEach((placed) => {
+ const tactic = TACTICS[placed.tactic_type];
+ if (!tactic) return;
+ const token = document.createElement("button");
+ token.type = "button";
+ token.className = `map-token ${placed.tactic_type}`;
+ token.draggable = true;
+ token.dataset.tacticId = placed.tactic_id;
+ const tacticIndex = renderedTacticsByJunction.get(placed.junction_id) || 0;
+ renderedTacticsByJunction.set(placed.junction_id, tacticIndex + 1);
+ const colocatedWithWitness = witnessJunctions.has(placed.junction_id);
+ const tacticCount = tacticCountsByJunction.get(placed.junction_id) || 1;
+ if (colocatedWithWitness || tacticCount > 1) {
+ token.classList.add("co-located");
+ const offset = tacticStackOffset(tacticIndex, tacticCount);
+ token.style.setProperty("--token-offset-x", `${offset.x}px`);
+ token.style.setProperty("--token-offset-y", `${offset.y}px`);
+ }
+ token.innerHTML = ` `;
+ token.addEventListener("dragstart", (event) => {
+ event.dataTransfer.setData("application/x-placed-tactic-id", placed.tactic_id);
+ event.dataTransfer.effectAllowed = "move";
+ });
+ placeAtMapPoint(token, placed.x, placed.y);
+ els.tacticLayer.append(token);
+ });
+}
+
+function tacticStackOffset(index, total) {
+ if (total <= 1) return { x: 0, y: 0 };
+ if (total === 2) {
+ const spread = 44;
+ return { x: index === 0 ? -spread : spread, y: 0 };
+ }
+ const radius = 46;
+ const angle = (-Math.PI / 2) + (index * Math.PI * 2) / total;
+ return { x: Math.round(Math.cos(angle) * radius), y: Math.round(Math.sin(angle) * radius) };
+}
+
+function witnessReportOffset(index, total, colocatedWithTactic) {
+ if (total === 1) return { x: colocatedWithTactic ? -28 : 0, y: 0 };
+ const ringIndex = Math.floor(index / 8);
+ const position = index % 8;
+ const itemsInRing = Math.min(8, total - ringIndex * 8);
+ const radius = 26 + ringIndex * 18 + (colocatedWithTactic ? 8 : 0);
+ const angle = (-Math.PI / 2) + (position * Math.PI * 2) / itemsInRing;
+ return { x: Math.round(Math.cos(angle) * radius), y: Math.round(Math.sin(angle) * radius) };
+}
+
+function toggleMapVisibility(category) {
+ state.mapVisibility[category] = !state.mapVisibility[category];
+ renderMapVisibilityControls();
+ renderMapOverlays();
+}
+
+function enableWitnessMode() {
+ state.mapVisibility.witnesses = true;
+ state.mapVisibility.tactics = false;
+ state.mapVisibility.focus = false;
+ renderMapVisibilityControls();
+ renderMapOverlays();
+}
+
+function renderMapVisibilityControls() {
+ const controls = [
+ [els.toggleWitnessesButton, "witnesses"],
+ [els.toggleTacticsButton, "tactics"],
+ [els.toggleFocusButton, "focus"],
+ ];
+ controls.forEach(([button, category]) => {
+ const visible = state.mapVisibility[category];
+ button.classList.toggle("active", visible);
+ button.setAttribute("aria-pressed", String(visible));
+ });
+ els.witnessModeButton.classList.toggle(
+ "active",
+ state.mapVisibility.witnesses && !state.mapVisibility.tactics && !state.mapVisibility.focus,
+ );
+}
+
+function renderLookout(lookout) {
+ if (!lookout || !lookout.raised) {
+ els.lookoutMeta.textContent = "No witness pins yet.";
+ return;
+ }
+ const review = lookout.review_allowed ? "statements available" : "crowd reports only";
+ els.lookoutMeta.textContent = `${lookout.witness_count} potential witnesses, ${review}.`;
+}
+
+function renderStatements() {
+ els.statementList.innerHTML = "";
+ if (!state.previousStatements.length) {
+ const empty = document.createElement("article");
+ empty.className = "statement-card empty";
+ empty.innerHTML = "No statements yet Ask a witness statement to pin it here.
";
+ els.statementList.append(empty);
+ return;
+ }
+ state.previousStatements.slice().reverse().forEach((statement) => {
+ const card = document.createElement("article");
+ card.className = "statement-card";
+ const observedTurn = statement.observed_turn ?? statement.turn;
+ card.innerHTML = `
+
+ ${String(statement.junction_id).padStart(2, "0")} Junction ${statement.junction_id}
+ Saw on Turn ${observedTurn} - ${escapeHtml(statement.time_label || "")}
+
+ ${escapeHtml(shortSummary(statement.answer || statement.summary, 118))}
+ OK
+ `;
+ els.statementList.append(card);
+ });
+}
+
+function renderActiveUnits() {
+ const total = state.tacticCounts.total_limit ?? 12;
+ const remaining = state.tacticCounts.total_remaining ?? total;
+ els.activeUnitsText.textContent = `${remaining} / ${total} left`;
+ els.unitIcons.innerHTML = "";
+ for (let index = 0; index < total; index += 1) {
+ const dot = document.createElement("span");
+ dot.className = index < remaining ? "unit-dot ready" : "unit-dot used";
+ els.unitIcons.append(dot);
+ }
+}
+
+function renderLegend() {
+ const items = [
+ ["pin_unviewed_witness.png", "Unviewed Witness", "Lead"],
+ ["pin_viewed_witness.png", "Viewed Witness", "Cleared"],
+ ["pin_roadblock.png", "Roadblock", "Blocks Road"],
+ ["pin_junction_lockdown.png", "Junction Lockdown", "Blocks Area"],
+ ["pin_patrol_unit.png", "Patrol Unit", "Patrolling"],
+ ["pin_search_team.png", "Search Team", "Investigating"],
+ ["pin_lookout_board.png", "Lookout Board", "Alerts"],
+ ];
+ els.legendStrip.innerHTML = "";
+ items.forEach(([icon, label, detail]) => {
+ const item = document.createElement("div");
+ item.className = "legend-item";
+ item.innerHTML = `${label} ${detail} `;
+ els.legendStrip.append(item);
+ });
+}
+
+async function handleMapClick(event) {
+ if (state.suppressMapClick) {
+ state.suppressMapClick = false;
+ return;
+ }
+ if (event.target.closest(".map-token, .witness-token")) return;
+ const point = naturalPointFromEvent(event);
+ if (!point) return;
+ const junctionId = nearestJunction(point);
+ if (!junctionId) return;
+ state.focused = junctionId;
+ state.selected = [junctionId];
+ renderMapOverlays();
+ applySnapshot(await api("select_junctions", payload()));
+}
+
+function startMapPan(event) {
+ if (event.button !== 0) return;
+ if (event.target.closest(".map-token, .witness-token, .map-controls, .detail-popup")) return;
+ state.mapPan = {
+ pointerId: event.pointerId,
+ startX: event.clientX,
+ startY: event.clientY,
+ originX: state.mapView.x,
+ originY: state.mapView.y,
+ moved: false,
+ };
+}
+
+function moveMapPan(event) {
+ const pan = state.mapPan;
+ if (!pan || pan.pointerId !== event.pointerId || state.pointerDrag) return;
+ const dx = (event.clientX - pan.startX) / state.appScale;
+ const dy = (event.clientY - pan.startY) / state.appScale;
+ if (Math.hypot(dx, dy) > 6) {
+ pan.moved = true;
+ }
+ state.mapView.x = pan.originX + dx;
+ state.mapView.y = pan.originY + dy;
+ clampMapView();
+ renderMapView();
+}
+
+function endMapPan(event) {
+ const pan = state.mapPan;
+ if (!pan || pan.pointerId !== event.pointerId) return;
+ if (pan.moved) {
+ state.suppressMapClick = true;
+ }
+ state.mapPan = null;
+}
+
+function cancelMapPan() {
+ state.mapPan = null;
+}
+
+function handleMapWheel(event) {
+ if (!event.target.closest("#mapWrap")) return;
+ event.preventDefault();
+ zoomBy(event.deltaY < 0 ? 1.12 : 0.89, event.clientX, event.clientY);
+}
+
+async function handleMapDrop(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ const tacticType = event.dataTransfer.getData("application/x-tactic-type");
+ const movedTactic = event.dataTransfer.getData("application/x-placed-tactic-id");
+ if (movedTactic) return;
+ if (!tacticType) return;
+ const point = naturalPointFromEvent(event);
+ const junctionId = point ? nearestJunction(point) : null;
+ if (!junctionId) {
+ flash("Drop the tactic closer to a junction.", "map_select");
+ return;
+ }
+ if ((state.tacticCounts.remaining?.[tacticType] ?? 0) <= 0) {
+ flash(`No ${TACTICS[tacticType].label} units remain.`, "map_select");
+ return;
+ }
+ await placeTacticAt(tacticType, junctionId);
+}
+
+async function handleDocumentDrop(event) {
+ const tacticId = event.dataTransfer.getData("application/x-placed-tactic-id");
+ if (!tacticId || event.target.closest("#mapWrap")) return;
+ event.preventDefault();
+ closePopup();
+ applySnapshot(await api("remove_tactic", payload({ tactic_id: tacticId })));
+}
+
+function startTrayPointerDrag(event) {
+ const card = event.target.closest(".tactic-card");
+ if (!card || card.disabled) return;
+ const tacticType = card.dataset.tacticType;
+ if (!tacticType || (state.tacticCounts.remaining?.[tacticType] ?? 0) <= 0) return;
+ event.preventDefault();
+ beginPointerDrag(event, {
+ kind: "new",
+ tacticType,
+ label: TACTICS[tacticType].label,
+ icon: TACTICS[tacticType].icon,
+ });
+}
+
+function startPlacedPointerDrag(event) {
+ const token = event.target.closest(".map-token");
+ if (!token) return;
+ const placed = state.placedTactics.find((item) => item.tactic_id === token.dataset.tacticId);
+ if (!placed) return;
+ const tactic = TACTICS[placed.tactic_type];
+ if (!tactic) return;
+ event.preventDefault();
+ beginPointerDrag(event, {
+ kind: "placed",
+ tacticId: placed.tactic_id,
+ label: tactic.label,
+ icon: tactic.pin,
+ });
+}
+
+function beginPointerDrag(event, detail) {
+ closePopup();
+ const ghost = document.createElement("div");
+ ghost.className = "drag-ghost";
+ ghost.innerHTML = `${escapeHtml(detail.label)} `;
+ document.body.append(ghost);
+ state.pointerDrag = {
+ ...detail,
+ pointerId: event.pointerId,
+ startX: event.clientX,
+ startY: event.clientY,
+ moved: false,
+ ghost,
+ };
+ positionDragGhost(event.clientX, event.clientY);
+}
+
+function movePointerDrag(event) {
+ const drag = state.pointerDrag;
+ if (!drag || drag.pointerId !== event.pointerId) return;
+ if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 8) {
+ drag.moved = true;
+ }
+ positionDragGhost(event.clientX, event.clientY);
+}
+
+async function endPointerDrag(event) {
+ const drag = state.pointerDrag;
+ if (!drag || drag.pointerId !== event.pointerId) return;
+ state.pointerDrag = null;
+ drag.ghost.remove();
+
+ const point = naturalPointFromClient(event.clientX, event.clientY);
+ if (drag.kind === "new") {
+ if (!drag.moved) return;
+ const junctionId = point ? nearestJunction(point) : null;
+ if (!junctionId) {
+ flash("Drop the tactic closer to a junction.", "map_select");
+ return;
+ }
+ await placeTacticAt(drag.tacticType, junctionId);
+ return;
+ }
+
+ if (drag.kind === "placed" && drag.moved && !point) {
+ applySnapshot(await api("remove_tactic", payload({ tactic_id: drag.tacticId })));
+ }
+}
+
+function cancelPointerDrag() {
+ if (state.pointerDrag?.ghost) {
+ state.pointerDrag.ghost.remove();
+ }
+ state.pointerDrag = null;
+}
+
+function positionDragGhost(x, y) {
+ const ghost = state.pointerDrag?.ghost;
+ if (!ghost) return;
+ ghost.style.left = `${x + 12}px`;
+ ghost.style.top = `${y + 12}px`;
+}
+
+async function placeTacticAt(tacticType, junctionId) {
+ state.focused = junctionId;
+ state.selected = [junctionId];
+ optimisticCount(tacticType, -1);
+ renderTacticTray();
+ renderActiveUnits();
+ try {
+ applySnapshot(await api("place_tactic", payload({
+ tactic_type: tacticType,
+ junction_id: junctionId,
+ layer: state.layer,
+ })));
+ } catch (error) {
+ optimisticCount(tacticType, 1);
+ renderTacticTray();
+ renderActiveUnits();
+ flash(error.message || "Could not place tactic.", "map_select");
+ }
+}
+
+function handleTacticClick(event) {
+ const token = event.target.closest("[data-tactic-id]");
+ if (!token) return;
+ event.preventDefault();
+ event.stopPropagation();
+ const placed = state.placedTactics.find((item) => item.tactic_id === token.dataset.tacticId);
+ if (!placed) return;
+ showTacticPopup(placed, event.clientX, event.clientY);
+}
+
+function handleWitnessClick(event) {
+ const cluster = event.target.closest("[data-witness-cluster-junction]");
+ if (cluster) {
+ event.preventDefault();
+ event.stopPropagation();
+ const junctionId = Number(cluster.dataset.witnessClusterJunction);
+ const location = state.witnesses.find((item) => item.junction_id === junctionId);
+ showWitnessClusterPopup(location, event.clientX, event.clientY);
+ return;
+ }
+ const token = event.target.closest("[data-witness-id]");
+ if (!token) return;
+ event.preventDefault();
+ event.stopPropagation();
+ const witnessId = token.dataset.witnessId;
+ openWitnessInterview(witnessId);
+}
+
+function handlePopupClick(event) {
+ const close = event.target.closest("[data-action='close-popup']");
+ if (close) {
+ closePopup();
+ return;
+ }
+ const remove = event.target.closest("[data-action='remove-tactic']");
+ if (remove) {
+ api("remove_tactic", payload({ tactic_id: remove.dataset.tacticId }))
+ .then((snapshot) => {
+ closePopup();
+ applySnapshot(snapshot);
+ })
+ .catch((error) => flash(error.message || "Could not remove tactic.", "map_select"));
+ return;
+ }
+ const ask = event.target.closest("[data-action='ask-witness']");
+ if (ask) {
+ askWitness(ask.dataset.witnessId);
+ return;
+ }
+ const openWitness = event.target.closest("[data-action='open-witness']");
+ if (openWitness) {
+ openWitnessInterview(openWitness.dataset.witnessId);
+ }
+}
+
+function showTacticPopup(placed, x, y) {
+ const tactic = TACTICS[placed.tactic_type];
+ if (!tactic) return;
+ state.focused = placed.junction_id;
+ state.selected = [placed.junction_id];
+ renderMapOverlays();
+ els.detailPopup.innerHTML = `
+
+
+ ${escapeHtml(tactic.label)}
+ ${escapeHtml(tactic.details)}
+
+ Junction ${placed.junction_id}
+ Turn Placed ${placed.turn_created}
+
+ Remove
+ `;
+ placePopup(x, y);
+}
+
+function showWitnessPopup(location, card, x, y) {
+ if (!location) return;
+ const witnessId = card?.id || location.sample_witness_id;
+ const canAsk = Boolean(witnessId);
+ state.focused = location.junction_id;
+ state.selected = [location.junction_id];
+ renderMapOverlays();
+ const observedTurn = card?.observed_turn ?? location.reports?.[0]?.observed_turn ?? null;
+ els.detailPopup.innerHTML = `
+
+
+ ${location.viewed ? "Viewed Witness" : "Unviewed Witness"}
+ ${escapeHtml(shortSummary(card?.summary || location.sample_summary || "Potential witness report.", 160))}
+
+ Junction ${location.junction_id}
+ Reports ${location.count}
+ ${observedTurn != null ? `Saw on Turn ${observedTurn} ` : ""}
+
+ ${canAsk ? `Ask Statement ` : ""}
+ `;
+ placePopup(x, y);
+}
+
+function showWitnessClusterPopup(location, x, y) {
+ if (!location?.reports?.length) return;
+ state.focused = location.junction_id;
+ state.selected = [location.junction_id];
+ renderMapOverlays();
+ const reportButtons = location.reports.map((report, index) => `
+
+ Report ${index + 1}: ${escapeHtml(report.name || report.style || "Witness")}
+ Saw on Turn ${report.observed_turn ?? "?"}
+ ${escapeHtml(shortSummary(report.summary || "Potential witness report.", 92))}
+
+ `).join("");
+ els.detailPopup.innerHTML = `
+
+ ${location.reports.length} Witness Reports
+ Junction ${location.junction_id}. Select a report to interview that witness.
+ ${reportButtons}
+ `;
+ placePopup(x, y);
+}
+
+async function askWitness(witnessId) {
+ if (!witnessId) return;
+ try {
+ applySnapshot(await api("ask_witness", payload({ witness_id: witnessId, question: "Which direction were they moving?" })));
+ closePopup();
+ } catch (error) {
+ flash(error.message || "Could not ask witness.", "map_select");
+ }
+}
+
+function placePopup(x, y) {
+ const margin = 18;
+ els.detailPopup.hidden = false;
+ const width = 280;
+ const left = Math.min(Math.max(x + 14, margin), window.innerWidth - width - margin);
+ const top = Math.min(Math.max(y + 14, margin), window.innerHeight - 320);
+ els.detailPopup.style.left = `${left}px`;
+ els.detailPopup.style.top = `${Math.max(top, margin)}px`;
+}
+
+function closePopup() {
+ els.detailPopup.hidden = true;
+ els.detailPopup.innerHTML = "";
+}
+
+function optimisticCount(tacticType, delta) {
+ if (!state.tacticCounts.remaining || !(tacticType in state.tacticCounts.remaining)) return;
+ state.tacticCounts.remaining[tacticType] = Math.max(0, state.tacticCounts.remaining[tacticType] + delta);
+ state.tacticCounts.total_remaining = Math.max(0, state.tacticCounts.total_remaining + delta);
+}
+
+function nearestJunction(point) {
+ let best = null;
+ let bestDistance = 64;
+ for (const junction of state.map.junctions) {
+ const distance = Math.hypot(point.x - junction.x, point.y - junction.y);
+ if (distance <= bestDistance) {
+ best = junction.id;
+ bestDistance = distance;
+ }
+ }
+ return best;
+}
+
+function naturalPointFromEvent(event) {
+ return naturalPointFromClient(event.clientX, event.clientY);
+}
+
+function naturalPointFromClient(clientX, clientY) {
+ const wrap = els.mapWrap.getBoundingClientRect();
+ const base = imageBaseRect();
+ if (!wrap.width || !base) {
+ return null;
+ }
+ const localX = (clientX - wrap.left) / state.appScale;
+ const localY = (clientY - wrap.top) / state.appScale;
+ const canvasX = (localX - state.mapView.x) / state.mapView.zoom;
+ const canvasY = (localY - state.mapView.y) / state.mapView.zoom;
+ if (canvasX < base.left || canvasX > base.right || canvasY < base.top || canvasY > base.bottom) return null;
+ return {
+ x: ((canvasX - base.left) / base.width) * els.mapImage.naturalWidth,
+ y: ((canvasY - base.top) / base.height) * els.mapImage.naturalHeight - currentLayerYOffset(),
+ };
+}
+
+function placeAtMapPoint(node, x, y) {
+ const rect = imageBaseRect();
+ if (!rect) return;
+ const offset = currentLayerYOffset();
+ const left = rect.left + (x / els.mapImage.naturalWidth) * rect.width;
+ const top = rect.top + ((y + offset) / els.mapImage.naturalHeight) * rect.height;
+ node.style.left = `${left}px`;
+ node.style.top = `${top}px`;
+}
+
+function imageBaseRect() {
+ const widthBox = els.mapWrap.clientWidth;
+ const heightBox = els.mapWrap.clientHeight;
+ if (!els.mapImage.naturalWidth || !els.mapImage.naturalHeight || !widthBox || !heightBox) return null;
+ const imageRatio = els.mapImage.naturalWidth / els.mapImage.naturalHeight;
+ const boxRatio = widthBox / heightBox;
+ let width = widthBox;
+ let height = heightBox;
+ let left = 0;
+ let top = 0;
+ if (boxRatio > imageRatio) {
+ width = height * imageRatio;
+ left += (widthBox - width) / 2;
+ } else {
+ height = width / imageRatio;
+ top += (heightBox - height) / 2;
+ }
+ return { left, top, width, height, right: left + width, bottom: top + height };
+}
+
+function junctionById(junctionId) {
+ return state.map.junctions.find((junction) => junction.id === junctionId);
+}
+
+function turnPhase(turn) {
+ return ["Morning", "Midday", "Afternoon", "Evening", "Night"][(Number(turn || 1) - 1) % 5];
+}
+
+function flash(message, sound, makeNoise = true) {
+ els.eventTicker.textContent = message;
+ els.mapMessage.textContent = message;
+ if (makeNoise && sound) playSound(sound);
+}
+
+function beginBusy(kind, message) {
+ if (state.busy) return false;
+ state.busy = kind;
+ const targets = [
+ { button: els.advanceButton, busyLabel: "Processing Turn..." },
+ { button: els.newCaseButton, busyLabel: "Opening Case..." },
+ { button: els.stopGameButton, busyLabel: "" },
+ { button: els.restartGameButton, busyLabel: "" },
+ ];
+ state.busyTargets = targets.map(({ button, busyLabel }) => ({
+ button,
+ label: button.textContent,
+ disabledBefore: button.disabled,
+ busyLabel,
+ }));
+ state.busyTargets.forEach(({ button, busyLabel }) => {
+ button.disabled = true;
+ if (button === (kind === "new_case" ? els.newCaseButton : els.advanceButton)) {
+ button.classList.add("processing");
+ if (busyLabel) button.textContent = busyLabel;
+ }
+ });
+ els.eventTicker.textContent = message;
+ els.mapMessage.textContent = message;
+ playSound("blockade_set");
+ return true;
+}
+
+function endBusy() {
+ if (!state.busyTargets) {
+ state.busy = null;
+ return;
+ }
+ state.busyTargets.forEach(({ button, label, disabledBefore }) => {
+ button.classList.remove("processing");
+ button.textContent = label;
+ button.disabled = disabledBefore;
+ });
+ state.busy = null;
+ state.busyTargets = null;
+ const complete = Boolean(state.game?.result || state.game?.phase === "complete");
+ els.advanceButton.disabled = complete;
+ els.stopGameButton.disabled = complete;
+}
+
+function beginTurnProcessing() {
+ return beginBusy("advance_turn", "Generating the next turn... this can take a while. Please wait.");
+}
+
+function endTurnProcessing() {
+ endBusy();
+}
+
+function beginNewCaseProcessing() {
+ return beginBusy("new_case", "Opening a new case... please wait.");
+}
+
+function endNewCaseProcessing() {
+ endBusy();
+}
+
+let audioContext = null;
+
+function playSound(name) {
+ if (!state.sound) return;
+ audioContext ||= new AudioContext();
+ const now = audioContext.currentTime;
+ if (name === "turn_advance") {
+ playChime([523.25, 783.99, 1046.5], 0.7, "sine", 0.32);
+ return;
+ }
+ const gain = audioContext.createGain();
+ gain.connect(audioContext.destination);
+ gain.gain.setValueAtTime(0.0001, now);
+ gain.gain.exponentialRampToValueAtTime(0.07, now + 0.02);
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.2);
+ const tones = {
+ map_select: [220, 0.12, "triangle"],
+ blockade_set: [110, 0.2, "square"],
+ lookout_raise: [330, 0.24, "sawtooth"],
+ witness_popup: [520, 0.18, "sine"],
+ turn_advance: [160, 0.3, "triangle"],
+ };
+ const [frequency, duration, type] = tones[name] || tones.map_select;
+ const oscillator = audioContext.createOscillator();
+ oscillator.type = type;
+ oscillator.frequency.setValueAtTime(frequency, now);
+ oscillator.frequency.exponentialRampToValueAtTime(frequency * 1.28, now + duration);
+ oscillator.connect(gain);
+ oscillator.start(now);
+ oscillator.stop(now + duration);
+}
+
+function playChime(frequencies, duration, type, peak) {
+ if (!audioContext) return;
+ const now = audioContext.currentTime;
+ frequencies.forEach((frequency, index) => {
+ const start = now + index * 0.12;
+ const gain = audioContext.createGain();
+ gain.connect(audioContext.destination);
+ gain.gain.setValueAtTime(0.0001, start);
+ gain.gain.exponentialRampToValueAtTime(peak, start + 0.02);
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
+ const oscillator = audioContext.createOscillator();
+ oscillator.type = type;
+ oscillator.frequency.setValueAtTime(frequency, start);
+ oscillator.connect(gain);
+ oscillator.start(start);
+ oscillator.stop(start + duration);
+ });
+}
+
+function escapeHtml(value) {
+ return String(value ?? "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function shortSummary(value, limit = 126) {
+ const clean = String(value || "Report received.").replace(/\s+/g, " ").trim();
+ if (clean.length <= limit) return clean;
+ return `${clean.slice(0, limit - 3)}...`;
+}
+
+function openNoticeDialog(prompt) {
+ els.noticeJunctionLabel.textContent = `Junction ${prompt.junction_id}`;
+ els.noticeText.value = prompt.prefill || DEFAULT_NOTICE;
+ els.lookoutMeta.textContent = "The wording controls which existing witnesses recognize the appeal.";
+ if (!els.noticeDialog.open) els.noticeDialog.showModal();
+ els.noticeText.focus();
+}
+
+async function publishNotice() {
+ if (!state.gameId) return;
+ try {
+ const snapshot = await api("issue_notice", payload({ notice_text: els.noticeText.value || DEFAULT_NOTICE }));
+ els.noticeDialog.close();
+ applySnapshot(snapshot);
+ } catch (error) {
+ els.lookoutMeta.textContent = error.message || "Could not publish this notice.";
+ }
+}
+
+function scheduleNotesSave() {
+ state.notesDirty = true;
+ els.notesStatus.textContent = "Saving...";
+ clearTimeout(state.notesTimer);
+ state.notesTimer = setTimeout(saveNotes, 500);
+}
+
+async function saveNotes() {
+ if (!state.gameId) return;
+ try {
+ const response = await fetch(`/api/game/${encodeURIComponent(state.gameId)}/notes`, {
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ notes: els.notesText.value }),
+ });
+ if (!response.ok) throw new Error("Could not save notes");
+ state.notesDirty = false;
+ els.notesStatus.textContent = "Saved with this case.";
+ } catch (error) {
+ els.notesStatus.textContent = error.message || "Notes not saved.";
+ }
+}
+
+async function openWitnessInterview(witnessId) {
+ if (!witnessId || !state.gameId) return;
+ closePopup();
+ try {
+ const response = await fetch(`/api/witness/${encodeURIComponent(state.gameId)}/${encodeURIComponent(witnessId)}`);
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.detail || "Could not open witness.");
+ state.activeWitness = data.witness;
+ els.witnessName.textContent = data.witness.name;
+ const observedTurn = data.witness.observed_turn != null ? ` | Saw on Turn ${data.witness.observed_turn}` : "";
+ els.witnessProfile.textContent = `${data.witness.occupation} | Junction ${data.witness.junction_id} | ${data.witness.personality.style || "measured"}${observedTurn}`;
+ els.witnessSummary.textContent = data.witness.summary;
+ els.witnessConnection.textContent = "Text + voice output ready | microphone idle";
+ els.witnessTranscript.innerHTML = "";
+ data.witness.transcript.forEach((turn) => {
+ appendChatMessage("user", turn.question);
+ appendChatMessage("witness", turn.answer);
+ });
+ if (!els.witnessDialog.open) els.witnessDialog.showModal();
+ els.witnessMessage.focus();
+ } catch (error) {
+ flash(error.message || "Could not open witness.", "map_select");
+ }
+}
+
+async function sendWitnessText() {
+ const witness = state.activeWitness;
+ const message = els.witnessMessage.value.trim();
+ if (!witness || !message) return;
+ els.witnessMessage.value = "";
+ appendChatMessage("user", message);
+ prepareVoicePlayback();
+ if (state.witnessSocket || state.speechMode) {
+ stopSpeechSession();
+ await new Promise((resolve) => setTimeout(resolve, 350));
+ }
+ els.sendWitnessMessage.disabled = true;
+ els.witnessConnection.textContent = "Witness is answering...";
+ try {
+ const response = await fetch(`/api/witness/${encodeURIComponent(state.gameId)}/${encodeURIComponent(witness.id)}/message`, {
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message }),
+ });
+ const raw = await response.text();
+ let data;
+ try {
+ data = raw ? JSON.parse(raw) : {};
+ } catch (_) {
+ throw new Error(`Server returned an invalid response (${response.status}).`);
+ }
+ if (!response.ok) throw new Error(data.detail || "Witness response failed.");
+ appendChatMessage("witness", data.answer);
+ if (data.audio_data) {
+ playFloat32Audio(data.audio_data, data.audio_sample_rate || 24000);
+ } else {
+ appendChatMessage("witness", "[Voice output was not returned by MiniCPM-o. Check Voice in Text Chat in Settings.]");
+ }
+ if (data.snapshot) applySnapshot(data.snapshot, false);
+ els.witnessConnection.textContent = data.audio_data ? "Text + voice output ready | microphone idle" : "Text ready | voice output unavailable";
+ } catch (error) {
+ appendChatMessage("witness", `[Connection error: ${error.message}]`);
+ els.witnessConnection.textContent = "MiniCPM-o unavailable";
+ } finally {
+ els.sendWitnessMessage.disabled = false;
+ }
+}
+
+function appendChatMessage(role, text) {
+ const bubble = document.createElement("article");
+ bubble.className = `chat-message ${role}`;
+ bubble.textContent = text;
+ els.witnessTranscript.append(bubble);
+ els.witnessTranscript.scrollTop = els.witnessTranscript.scrollHeight;
+ return bubble;
+}
+
+async function finishGame(reason) {
+ if (!state.gameId) return;
+ try {
+ const response = await fetch(`/api/game/${encodeURIComponent(state.gameId)}/stop`, {
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason }),
+ });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.detail || "Could not finish case.");
+ if (data.snapshot) applySnapshot(data.snapshot, false);
+ if (els.noticeDialog.open) els.noticeDialog.close();
+ if (els.witnessDialog.open) closeWitnessInterview();
+ showStoryReveal(data.story, reason === "restarted");
+ } catch (error) {
+ flash(error.message || "Could not finish case.", "map_select");
+ }
+}
+
+async function restartGame() {
+ if (!state.gameId) return openNewCase(true);
+ await finishGame("restarted");
+}
+
+async function loadStoryReveal() {
+ if (!state.gameId || els.storyDialog.open) return;
+ const response = await fetch(`/api/game/${encodeURIComponent(state.gameId)}/story`);
+ if (!response.ok) return;
+ const data = await response.json();
+ showStoryReveal(data.story, false);
+}
+
+function showStoryReveal(story, offerRestart) {
+ els.storyTimeline.innerHTML = "";
+ (story.segments || []).forEach((segment) => {
+ const card = document.createElement("article");
+ card.className = "story-card";
+ const facts = (segment.observable_facts || []).map((fact) => `${escapeHtml(fact.text)} `).join("");
+ card.innerHTML = `Turn ${segment.turn_number}: Junction ${segment.from_junction} to ${segment.to_junction} ${escapeHtml(segment.mode)} | ${segment.changed_disguise ? "disguise changed" : "same disguise"}
${escapeHtml(segment.narrative)}
${facts ? `` : ""}`;
+ els.storyTimeline.append(card);
+ });
+ els.storyFooter.innerHTML = offerRestart ? 'Confirm New Case ' : `Case result: ${escapeHtml(story.result || story.finalized_reason || "complete")}
`;
+ if (offerRestart) document.querySelector("#confirmRestartButton").addEventListener("click", async () => {
+ els.storyDialog.close(); state.gameId = null; state.game = null; await openNewCase(true);
+ });
+ if (!els.storyDialog.open) els.storyDialog.showModal();
+}
+
+function formatBytes(value) {
+ if (!value) return "0 B";
+ const units = ["B", "KB", "MB", "GB"];
+ const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
+ return `${(value / (1024 ** index)).toFixed(index > 1 ? 1 : 0)} ${units[index]}`;
+}
+
+async function toggleAutoSpeech() {
+ if (state.speechMode === "auto") return stopSpeechSession();
+ state.speechMode = "auto";
+ els.autoSpeechButton.classList.add("active");
+ els.autoSpeechButton.textContent = "Stop Auto Speech";
+ await startSpeechSession();
+}
+
+async function startPushToTalk(event) {
+ event.preventDefault();
+ state.speechMode = "push";
+ state.pushRecording = true;
+ state.pushDrainUntil = 0;
+ els.pushToTalkButton.classList.add("recording");
+ els.pushToTalkButton.textContent = "Listening...";
+ if (!state.witnessSocket || state.witnessSocket.readyState > 1) await startSpeechSession();
+}
+
+function stopPushToTalk() {
+ state.pushRecording = false;
+ state.pushDrainUntil = Date.now() + 1000;
+ els.pushToTalkButton.classList.remove("recording");
+ els.pushToTalkButton.textContent = "Hold to Talk";
+}
+
+async function startSpeechSession() {
+ if (!state.activeWitness || !state.gameId) return;
+ try {
+ if (!state.mediaStream) {
+ state.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
+ }
+ const proto = location.protocol === "https:" ? "wss" : "ws";
+ const url = `${proto}://${location.host}/ws/witness/${encodeURIComponent(state.gameId)}/${encodeURIComponent(state.activeWitness.id)}`;
+ const socket = new WebSocket(url);
+ state.witnessSocket = socket;
+ els.witnessConnection.textContent = "Connecting speech...";
+ socket.onopen = () => socket.send(JSON.stringify({ type: "prepare", config: {} }));
+ socket.onmessage = (event) => handleSpeechMessage(JSON.parse(event.data));
+ socket.onclose = () => {
+ els.witnessConnection.textContent = "Text + voice output ready | microphone idle";
+ stopCapture();
+ state.witnessSocket = null;
+ state.speechMode = null;
+ els.autoSpeechButton.classList.remove("active");
+ els.autoSpeechButton.textContent = "Start Auto Speech";
+ };
+ socket.onerror = () => { els.witnessConnection.textContent = "Speech connection failed"; };
+ } catch (error) {
+ els.witnessConnection.textContent = error.message || "Microphone permission failed";
+ state.speechMode = null;
+ }
+}
+
+async function handleSpeechMessage(message) {
+ if (message.type === "queued" || message.type === "queue_update") {
+ els.witnessConnection.textContent = `Speech queued #${message.position}`;
+ } else if (message.type === "prepared") {
+ els.witnessConnection.textContent = state.speechMode === "auto" ? "Listening automatically" : "Push to talk ready";
+ await startCapture();
+ } else if (message.type === "vad_state") {
+ els.witnessConnection.textContent = message.speaking ? "Listening..." : "Waiting for speech";
+ } else if (message.type === "generating") {
+ appendChatMessage("user", "[Spoken question]");
+ state.currentAssistantBubble = appendChatMessage("witness", "");
+ els.witnessConnection.textContent = "Witness is answering...";
+ } else if (message.type === "chunk") {
+ if (message.text_delta) {
+ if (!state.currentAssistantBubble) state.currentAssistantBubble = appendChatMessage("witness", "");
+ state.currentAssistantBubble.textContent += message.text_delta;
+ els.witnessTranscript.scrollTop = els.witnessTranscript.scrollHeight;
+ }
+ if (message.audio_data) playFloat32Audio(message.audio_data, message.audio_sample_rate || 24000);
+ } else if (message.type === "turn_done") {
+ state.currentAssistantBubble = null;
+ els.witnessConnection.textContent = state.speechMode === "auto" ? "Listening automatically" : "Push to talk ready";
+ } else if (message.type === "error") {
+ els.witnessConnection.textContent = message.error || "Speech error";
+ }
+}
+
+async function startCapture() {
+ if (state.captureContext || !state.mediaStream) return;
+ const context = new AudioContext();
+ const source = context.createMediaStreamSource(state.mediaStream);
+ const processor = context.createScriptProcessor(4096, 1, 1);
+ const silent = context.createGain();
+ silent.gain.value = 0;
+ processor.onaudioprocess = (event) => {
+ const input = event.inputBuffer.getChannelData(0);
+ let sum = 0;
+ for (const value of input) sum += value * value;
+ els.micLevel.value = Math.min(Math.sqrt(sum / input.length) * 8, 1);
+ const shouldSend = state.speechMode === "auto" || (
+ state.speechMode === "push" && (state.pushRecording || Date.now() < state.pushDrainUntil)
+ );
+ if (!shouldSend || state.witnessSocket?.readyState !== WebSocket.OPEN) return;
+ const audio = resampleAudio(input, context.sampleRate, 16000);
+ state.witnessSocket.send(JSON.stringify({ type: "audio_chunk", audio_base64: float32ToBase64(audio) }));
+ };
+ source.connect(processor); processor.connect(silent); silent.connect(context.destination);
+ state.captureContext = context; state.captureNode = processor;
+}
+
+function resampleAudio(input, sourceRate, targetRate) {
+ if (sourceRate === targetRate) return new Float32Array(input);
+ const ratio = sourceRate / targetRate;
+ const output = new Float32Array(Math.floor(input.length / ratio));
+ for (let i = 0; i < output.length; i += 1) {
+ const start = Math.floor(i * ratio); const end = Math.min(Math.floor((i + 1) * ratio), input.length);
+ let sum = 0; for (let j = start; j < end; j += 1) sum += input[j];
+ output[i] = sum / Math.max(end - start, 1);
+ }
+ return output;
+}
+
+function float32ToBase64(floatArray) {
+ const bytes = new Uint8Array(floatArray.buffer); let binary = "";
+ for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
+ return btoa(binary);
+}
+
+function prepareVoicePlayback() {
+ if (!state.playbackContext) state.playbackContext = new AudioContext();
+ if (state.playbackContext.state === "suspended") state.playbackContext.resume().catch(() => {});
+}
+
+function playFloat32Audio(base64Data, sampleRate) {
+ const binary = atob(base64Data); const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
+ const usable = bytes.byteLength - (bytes.byteLength % 4);
+ const floats = new Float32Array(bytes.buffer.slice(0, usable));
+ prepareVoicePlayback();
+ const context = state.playbackContext; const buffer = context.createBuffer(1, floats.length, sampleRate);
+ buffer.copyToChannel(floats, 0);
+ const source = context.createBufferSource(); source.buffer = buffer; source.connect(context.destination);
+ const start = Math.max(context.currentTime + 0.03, state.playbackTime || 0);
+ source.start(start); state.playbackTime = start + buffer.duration; state.playbackSources.push(source);
+ source.onended = () => { state.playbackSources = state.playbackSources.filter((item) => item !== source); };
+}
+
+function stopPlayback() {
+ state.playbackSources.forEach((source) => { try { source.stop(); } catch (_) {} });
+ state.playbackSources = []; state.playbackTime = 0;
+}
+
+function stopCapture() {
+ if (state.captureNode) state.captureNode.disconnect();
+ if (state.captureContext) state.captureContext.close();
+ state.captureNode = null; state.captureContext = null; els.micLevel.value = 0;
+}
+
+function stopSpeechSession() {
+ if (state.witnessSocket?.readyState === WebSocket.OPEN) state.witnessSocket.send(JSON.stringify({ type: "stop" }));
+ if (state.witnessSocket) state.witnessSocket.close();
+ stopCapture(); state.speechMode = null; state.pushRecording = false; state.pushDrainUntil = 0;
+ els.autoSpeechButton.classList.remove("active"); els.autoSpeechButton.textContent = "Start Auto Speech";
+ els.pushToTalkButton.classList.remove("recording"); els.pushToTalkButton.textContent = "Hold to Talk";
+}
+
+function closeWitnessInterview() {
+ stopSpeechSession(); stopPlayback();
+ if (state.mediaStream) state.mediaStream.getTracks().forEach((track) => track.stop());
+ state.mediaStream = null; state.activeWitness = null; els.witnessDialog.close();
+}
+
+boot().catch((error) => {
+ flash(error.message || "The board failed to open.", "map_select", false);
+});
diff --git a/ui/web/static/asset_prompts.json b/ui/web/static/asset_prompts.json
new file mode 100644
index 0000000000000000000000000000000000000000..2bb0c000305ed1a8cac33ef95a885f3a33cb7673
--- /dev/null
+++ b/ui/web/static/asset_prompts.json
@@ -0,0 +1,33 @@
+{
+ "case_table_background": "top-down view of a moody London detective desk, paper map, pins, string, chalk dust, warm lamp light, stylized game UI background, no text",
+ "suspect_placeholder": "anonymous noir suspect silhouette in a grey raincoat holding a red folder, graphic novel style, transparent background, no text",
+ "witness_card_set": "four small portrait cards of London street witnesses, varied ages and moods, 1930s detective board style, consistent illustration style, no text",
+ "lookout_board_texture": "green-black chalkboard with faint chalk smudges and taped paper edges, game UI texture, no readable text",
+ "map_select": "short tactile wooden token tap on a board, warm room tone, 0.3 seconds",
+ "blockade_set": "metal stamp clack with soft paper thud, detective office, 0.5 seconds",
+ "lookout_raise": "chalk scrape and corkboard paper rustle, subtle, 0.8 seconds",
+ "witness_popup": "quick paper card flick with faint bell, playful noir, 0.4 seconds",
+ "turn_advance": "old clock tick plus distant city ambience swell, 1 second",
+ "reference_asset_manifest": "/static/assets/reference/reference_assets.json",
+ "frame_header": "Teal and gold top banner frame used as a styling reference; title text remains editable HTML.",
+ "crest_frame": "Left agency crest panel reference; bureau text is recreated/editable in HTML where practical.",
+ "wanted_card_frame": "Parchment wanted poster frame and paper texture; suspect facts remain editable HTML.",
+ "suspect_portrait_placeholder_crop": "Temporary noir suspect portrait crop used until a replaceable generated portrait is available.",
+ "side_panel_frame": "Right teal/gold side panel frame reference for CSS panel styling.",
+ "paper_note_frame": "Parchment note frame for lookout and statement panels; text remains editable HTML.",
+ "tactic_tile_frame": "Parchment tactic card frame reference; labels and counts remain editable HTML.",
+ "icon_roadblock": "Barricade tactic icon for roadblock tiles and map tokens.",
+ "icon_junction_lockdown": "Junction barrier tactic icon for lockdown tiles and map tokens.",
+ "icon_patrol_unit": "Police helmet tactic icon for patrol unit tiles and map tokens.",
+ "icon_search_team": "Magnifier/team tactic icon for search team tiles and map tokens.",
+ "icon_lookout_board": "Notice-board tactic icon for lookout board tiles and map tokens.",
+ "pin_unviewed_witness": "Red witness lead pin shown before a witness statement is asked.",
+ "pin_viewed_witness": "Green checked witness pin shown after a witness statement is asked.",
+ "pin_roadblock": "Roadblock map token crop for placed route blocks.",
+ "pin_junction_lockdown": "Junction lockdown token crop for placed junction blocks.",
+ "pin_patrol_unit": "Patrol unit map token crop for placed patrols.",
+ "pin_search_team": "Search team map token crop for placed investigations.",
+ "pin_lookout_board": "Lookout board map token crop for placed public notice boards.",
+ "button_advance_frame": "Blue and gold advance button style reference; button text remains editable HTML.",
+ "legend_strip_frame": "Bottom parchment legend strip reference; legend labels remain editable HTML."
+}
diff --git a/ui/web/static/assets/reference/_asset_contact_sheet.png b/ui/web/static/assets/reference/_asset_contact_sheet.png
new file mode 100644
index 0000000000000000000000000000000000000000..0373d31b2a7b48a409f3cd99b4c10bc6079b883a
--- /dev/null
+++ b/ui/web/static/assets/reference/_asset_contact_sheet.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:859485e56f1bd4563b13b5303dc864114ed3369fd3b4921b23f45e0fdbb1d672
+size 165728
diff --git a/ui/web/static/assets/reference/button_advance_frame.png b/ui/web/static/assets/reference/button_advance_frame.png
new file mode 100644
index 0000000000000000000000000000000000000000..3e9e583067f375ffe7ba00cb3031e0a88424c74c
Binary files /dev/null and b/ui/web/static/assets/reference/button_advance_frame.png differ
diff --git a/ui/web/static/assets/reference/crest_frame.png b/ui/web/static/assets/reference/crest_frame.png
new file mode 100644
index 0000000000000000000000000000000000000000..43ca50aa5b14ad553662a7949539bd420ffdd2f5
Binary files /dev/null and b/ui/web/static/assets/reference/crest_frame.png differ
diff --git a/ui/web/static/assets/reference/frame_header.png b/ui/web/static/assets/reference/frame_header.png
new file mode 100644
index 0000000000000000000000000000000000000000..ad3feccb26a00de3634a81dea8061c5439b2b72f
--- /dev/null
+++ b/ui/web/static/assets/reference/frame_header.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8c619698575458d0602fff76221fc6d86124ff4556f6c20986f2091d2bf37e42
+size 215591
diff --git a/ui/web/static/assets/reference/icon_junction_lockdown.png b/ui/web/static/assets/reference/icon_junction_lockdown.png
new file mode 100644
index 0000000000000000000000000000000000000000..b9e1ebbb4431ca963995abf255e36e58568ffe77
Binary files /dev/null and b/ui/web/static/assets/reference/icon_junction_lockdown.png differ
diff --git a/ui/web/static/assets/reference/icon_lookout_board.png b/ui/web/static/assets/reference/icon_lookout_board.png
new file mode 100644
index 0000000000000000000000000000000000000000..5677501604875a1886b2558af2871661f7908617
Binary files /dev/null and b/ui/web/static/assets/reference/icon_lookout_board.png differ
diff --git a/ui/web/static/assets/reference/icon_patrol_unit.png b/ui/web/static/assets/reference/icon_patrol_unit.png
new file mode 100644
index 0000000000000000000000000000000000000000..52cec0a089ba2f924415a8b1625fdffbba37a799
Binary files /dev/null and b/ui/web/static/assets/reference/icon_patrol_unit.png differ
diff --git a/ui/web/static/assets/reference/icon_roadblock.png b/ui/web/static/assets/reference/icon_roadblock.png
new file mode 100644
index 0000000000000000000000000000000000000000..ae28dc1edd484b1177d0e149afddcac7e0af18b3
Binary files /dev/null and b/ui/web/static/assets/reference/icon_roadblock.png differ
diff --git a/ui/web/static/assets/reference/icon_search_team.png b/ui/web/static/assets/reference/icon_search_team.png
new file mode 100644
index 0000000000000000000000000000000000000000..bcc76778f0de49d6d803d8d9c688698f89ed39ed
Binary files /dev/null and b/ui/web/static/assets/reference/icon_search_team.png differ
diff --git a/ui/web/static/assets/reference/legend_strip_frame.png b/ui/web/static/assets/reference/legend_strip_frame.png
new file mode 100644
index 0000000000000000000000000000000000000000..8a5f6642733dbe29675bcc5bf8bbddc712da044c
--- /dev/null
+++ b/ui/web/static/assets/reference/legend_strip_frame.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6f1d0c910043879b926937ac3c08cad0ec42f8ad16e36b4a42735620f6cbe47c
+size 146036
diff --git a/ui/web/static/assets/reference/paper_note_frame.png b/ui/web/static/assets/reference/paper_note_frame.png
new file mode 100644
index 0000000000000000000000000000000000000000..98556e5ff39b654c6452a25fe40f8e84784848e8
--- /dev/null
+++ b/ui/web/static/assets/reference/paper_note_frame.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:22b30ac044004c3bb6c78c2ddf410283cecaaa7f0f9e08f2bd7d1b0da88e913e
+size 165380
diff --git a/ui/web/static/assets/reference/pin_junction_lockdown.png b/ui/web/static/assets/reference/pin_junction_lockdown.png
new file mode 100644
index 0000000000000000000000000000000000000000..b2f5372f49b222b0bb1b828865122492c4fc9573
Binary files /dev/null and b/ui/web/static/assets/reference/pin_junction_lockdown.png differ
diff --git a/ui/web/static/assets/reference/pin_lookout_board.png b/ui/web/static/assets/reference/pin_lookout_board.png
new file mode 100644
index 0000000000000000000000000000000000000000..6ba58ffb75c32897b06d8861937ed3d57b653f94
Binary files /dev/null and b/ui/web/static/assets/reference/pin_lookout_board.png differ
diff --git a/ui/web/static/assets/reference/pin_patrol_unit.png b/ui/web/static/assets/reference/pin_patrol_unit.png
new file mode 100644
index 0000000000000000000000000000000000000000..484d79bfaaec2f7a30ee01a0b65e8195a1b0dcc7
Binary files /dev/null and b/ui/web/static/assets/reference/pin_patrol_unit.png differ
diff --git a/ui/web/static/assets/reference/pin_roadblock.png b/ui/web/static/assets/reference/pin_roadblock.png
new file mode 100644
index 0000000000000000000000000000000000000000..ffa647f75d7566f8b1740a8dd38a4de5367347d4
Binary files /dev/null and b/ui/web/static/assets/reference/pin_roadblock.png differ
diff --git a/ui/web/static/assets/reference/pin_search_team.png b/ui/web/static/assets/reference/pin_search_team.png
new file mode 100644
index 0000000000000000000000000000000000000000..5682bbd300cca225dba3b0f444274529e4b214ed
Binary files /dev/null and b/ui/web/static/assets/reference/pin_search_team.png differ
diff --git a/ui/web/static/assets/reference/pin_unviewed_witness.png b/ui/web/static/assets/reference/pin_unviewed_witness.png
new file mode 100644
index 0000000000000000000000000000000000000000..bdb37d4c0dd71508664ea85fdd21f5cbf498b42b
Binary files /dev/null and b/ui/web/static/assets/reference/pin_unviewed_witness.png differ
diff --git a/ui/web/static/assets/reference/pin_viewed_witness.png b/ui/web/static/assets/reference/pin_viewed_witness.png
new file mode 100644
index 0000000000000000000000000000000000000000..8e2f76546989644cb0c5bbeeb3e212692c3476c5
Binary files /dev/null and b/ui/web/static/assets/reference/pin_viewed_witness.png differ
diff --git a/ui/web/static/assets/reference/reference_assets.json b/ui/web/static/assets/reference/reference_assets.json
new file mode 100644
index 0000000000000000000000000000000000000000..d020317ace38caa3a28cf82810b81f7552ca1783
--- /dev/null
+++ b/ui/web/static/assets/reference/reference_assets.json
@@ -0,0 +1,277 @@
+{
+ "assets": {
+ "frame_header": {
+ "file": "/static/assets/reference/frame_header.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 300,
+ 7,
+ 1328,
+ 140
+ ],
+ "description": "Teal and gold top banner frame used as a styling reference; title text remains editable HTML.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "crest_frame": {
+ "file": "/static/assets/reference/crest_frame.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 7,
+ 7,
+ 284,
+ 168
+ ],
+ "description": "Left agency crest panel reference; bureau text is recreated/editable in HTML where practical.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "wanted_card_frame": {
+ "file": "/static/assets/reference/wanted_card_frame.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 14,
+ 178,
+ 273,
+ 621
+ ],
+ "description": "Parchment wanted poster frame and paper texture; suspect facts remain editable HTML.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "suspect_portrait_placeholder": {
+ "file": "/static/assets/reference/suspect_portrait_placeholder.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 43,
+ 247,
+ 241,
+ 470
+ ],
+ "description": "Temporary noir suspect portrait crop used until a replaceable generated portrait is available.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "side_panel_frame": {
+ "file": "/static/assets/reference/side_panel_frame.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 1354,
+ 141,
+ 1672,
+ 932
+ ],
+ "description": "Right teal/gold side panel frame reference for CSS panel styling.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "paper_note_frame": {
+ "file": "/static/assets/reference/paper_note_frame.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 1372,
+ 203,
+ 1657,
+ 501
+ ],
+ "description": "Parchment note frame for lookout and statement panels; text remains editable HTML.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "tactic_tile_frame": {
+ "file": "/static/assets/reference/tactic_tile_frame.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 303,
+ 183,
+ 507,
+ 288
+ ],
+ "description": "Parchment tactic tile frame reference; labels and counts remain editable HTML.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "icon_roadblock": {
+ "file": "/static/assets/reference/icon_roadblock.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 329,
+ 198,
+ 391,
+ 266
+ ],
+ "description": "Barricade tactic icon for roadblock tiles and map tokens.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "icon_junction_lockdown": {
+ "file": "/static/assets/reference/icon_junction_lockdown.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 544,
+ 198,
+ 604,
+ 266
+ ],
+ "description": "Junction barrier tactic icon for lockdown tiles and map tokens.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "icon_patrol_unit": {
+ "file": "/static/assets/reference/icon_patrol_unit.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 743,
+ 196,
+ 817,
+ 269
+ ],
+ "description": "Police helmet tactic icon for patrol unit tiles and map tokens.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "icon_search_team": {
+ "file": "/static/assets/reference/icon_search_team.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 961,
+ 205,
+ 1024,
+ 263
+ ],
+ "description": "Magnifier/team tactic icon for search team tiles and map tokens.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "icon_lookout_board": {
+ "file": "/static/assets/reference/icon_lookout_board.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 1182,
+ 205,
+ 1236,
+ 264
+ ],
+ "description": "Notice-board tactic icon for lookout board tiles and map tokens.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "pin_unviewed_witness": {
+ "file": "/static/assets/reference/pin_unviewed_witness.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 638,
+ 345,
+ 687,
+ 421
+ ],
+ "description": "Red witness lead pin shown before a witness statement is asked.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "pin_viewed_witness": {
+ "file": "/static/assets/reference/pin_viewed_witness.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 1013,
+ 345,
+ 1064,
+ 421
+ ],
+ "description": "Green checked witness pin shown after a witness statement is asked.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "pin_roadblock": {
+ "file": "/static/assets/reference/pin_roadblock.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 786,
+ 351,
+ 837,
+ 405
+ ],
+ "description": "Roadblock map token crop for placed route blocks.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "pin_junction_lockdown": {
+ "file": "/static/assets/reference/pin_junction_lockdown.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 540,
+ 871,
+ 599,
+ 928
+ ],
+ "description": "Junction lockdown token crop for placed junction blocks.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "pin_patrol_unit": {
+ "file": "/static/assets/reference/pin_patrol_unit.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 579,
+ 519,
+ 630,
+ 577
+ ],
+ "description": "Patrol unit map token crop for placed patrols.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "pin_search_team": {
+ "file": "/static/assets/reference/pin_search_team.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 989,
+ 450,
+ 1044,
+ 506
+ ],
+ "description": "Search team map token crop for placed investigations.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "pin_lookout_board": {
+ "file": "/static/assets/reference/pin_lookout_board.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 735,
+ 633,
+ 792,
+ 706
+ ],
+ "description": "Lookout board map token crop for placed public notice boards.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "button_advance_frame": {
+ "file": "/static/assets/reference/button_advance_frame.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 10,
+ 775,
+ 277,
+ 862
+ ],
+ "description": "Blue and gold advance button style reference; button text remains editable HTML.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ },
+ "legend_strip_frame": {
+ "file": "/static/assets/reference/legend_strip_frame.png",
+ "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png",
+ "crop_box": [
+ 290,
+ 859,
+ 1350,
+ 931
+ ],
+ "description": "Bottom parchment legend strip reference; legend labels remain editable HTML.",
+ "temporary": true,
+ "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS."
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/web/static/assets/reference/side_panel_frame.png b/ui/web/static/assets/reference/side_panel_frame.png
new file mode 100644
index 0000000000000000000000000000000000000000..65805ff50868827c0741cdb57576ead177001a41
--- /dev/null
+++ b/ui/web/static/assets/reference/side_panel_frame.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e6c3b09f785eb2b9d996cb8bc3cd74db81cdab87f1411f76446e7993c6bf7569
+size 459546
diff --git a/ui/web/static/assets/reference/suspect_portrait_placeholder.png b/ui/web/static/assets/reference/suspect_portrait_placeholder.png
new file mode 100644
index 0000000000000000000000000000000000000000..858e7bd8b033fba454850f8fae17bbaaf0e6cbac
Binary files /dev/null and b/ui/web/static/assets/reference/suspect_portrait_placeholder.png differ
diff --git a/ui/web/static/assets/reference/tactic_tile_frame.png b/ui/web/static/assets/reference/tactic_tile_frame.png
new file mode 100644
index 0000000000000000000000000000000000000000..701ded7948f4525bc09acb017d9f52b3f6ca012e
Binary files /dev/null and b/ui/web/static/assets/reference/tactic_tile_frame.png differ
diff --git a/ui/web/static/assets/reference/wanted_card_frame.png b/ui/web/static/assets/reference/wanted_card_frame.png
new file mode 100644
index 0000000000000000000000000000000000000000..928c94d1beb81c6448b6ee717ca44cc6385a5f19
--- /dev/null
+++ b/ui/web/static/assets/reference/wanted_card_frame.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:37996d1be553a1dfb4fd720769480b0ecdb96f0d42e816d6cb377a8f06e0020c
+size 219842
diff --git a/ui/web/static/assets/suspects/amber_quill.png b/ui/web/static/assets/suspects/amber_quill.png
new file mode 100644
index 0000000000000000000000000000000000000000..f1534208df936261c348051cef2f1b991296642b
--- /dev/null
+++ b/ui/web/static/assets/suspects/amber_quill.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:46545b896228b81b76cc297902708374e18fad289b69ea54d84ccb6c96482229
+size 654392
diff --git a/ui/web/static/assets/suspects/blue_hour.png b/ui/web/static/assets/suspects/blue_hour.png
new file mode 100644
index 0000000000000000000000000000000000000000..671b091a6cb50da992011cb5049a5df19e4e5cad
--- /dev/null
+++ b/ui/web/static/assets/suspects/blue_hour.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f70d2b92e80da510dc97eefdcf41a476406a3c0f91c699964697f576d9bcfef3
+size 627867
diff --git a/ui/web/static/assets/suspects/brass_moth.png b/ui/web/static/assets/suspects/brass_moth.png
new file mode 100644
index 0000000000000000000000000000000000000000..f85284ab9fa49256d318187aec0c50dd8061cd37
--- /dev/null
+++ b/ui/web/static/assets/suspects/brass_moth.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d9961be1e487e11d2787c9ba2eeceb4a42b015c86190fe030d7abdb34d366760
+size 617392
diff --git a/ui/web/static/assets/suspects/copper_saint.png b/ui/web/static/assets/suspects/copper_saint.png
new file mode 100644
index 0000000000000000000000000000000000000000..37f7a60dbd27ec793f7a8fc0e2a3bb39c1241ca8
--- /dev/null
+++ b/ui/web/static/assets/suspects/copper_saint.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:72cba34c03f1d1b387b5f18ec3aa81de639fa05e08eed191b93a4403b50b53cc
+size 618490
diff --git a/ui/web/static/assets/suspects/glass_finch.png b/ui/web/static/assets/suspects/glass_finch.png
new file mode 100644
index 0000000000000000000000000000000000000000..0833bee070bfe2b810f7d48c853d719830626397
--- /dev/null
+++ b/ui/web/static/assets/suspects/glass_finch.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3a525b0a0d30e5d60bab429cd8eb8a785bfe9ffcfa7c955f87a5a8785005fdac
+size 616365
diff --git a/ui/web/static/assets/suspects/green_signal.png b/ui/web/static/assets/suspects/green_signal.png
new file mode 100644
index 0000000000000000000000000000000000000000..6341d73d8de3a216f0bada27035c6d01ec03b050
--- /dev/null
+++ b/ui/web/static/assets/suspects/green_signal.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:206fb3428bb8c873acd2f4e82a37c211ee6bb2ee841a7f1e6c9149b7896807bf
+size 611314
diff --git a/ui/web/static/assets/suspects/ivory_rook.png b/ui/web/static/assets/suspects/ivory_rook.png
new file mode 100644
index 0000000000000000000000000000000000000000..52e0f7f83711146664cd1d04fd555f012af967ce
--- /dev/null
+++ b/ui/web/static/assets/suspects/ivory_rook.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:deef8a8b4fe508c4a1990dd76366993e26c1b0efc456bd794e81969222a194c8
+size 621764
diff --git a/ui/web/static/assets/suspects/madame_mercury.png b/ui/web/static/assets/suspects/madame_mercury.png
new file mode 100644
index 0000000000000000000000000000000000000000..d397c26d193056c2c36d92be3e484b3232307e89
--- /dev/null
+++ b/ui/web/static/assets/suspects/madame_mercury.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c5b5e0bce2ed7515c629acb8d7ac69a6c4fa69d2a37e3c5ed3a62f542dec09f1
+size 669988
diff --git a/ui/web/static/assets/suspects/rose_diamond.png b/ui/web/static/assets/suspects/rose_diamond.png
new file mode 100644
index 0000000000000000000000000000000000000000..faf2849509931208515ec226b960a87041b26e5e
--- /dev/null
+++ b/ui/web/static/assets/suspects/rose_diamond.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:64c9e960763ed81835bce4783f7dd155771a9773428120ddb01116102af04ab9
+size 645615
diff --git a/ui/web/static/assets/suspects/saffron_pen.png b/ui/web/static/assets/suspects/saffron_pen.png
new file mode 100644
index 0000000000000000000000000000000000000000..7a979b80a00ef201c0ecf6de362fcce21df4f273
--- /dev/null
+++ b/ui/web/static/assets/suspects/saffron_pen.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8492ce6a2e31d4a91905b289a69c5b44cba16f00ccdfd2234cdd4d5187073e7d
+size 655638
diff --git a/ui/web/static/assets/suspects/scarlet_lark.png b/ui/web/static/assets/suspects/scarlet_lark.png
new file mode 100644
index 0000000000000000000000000000000000000000..a5506296137b0b3995601895f91c333cb099fc83
--- /dev/null
+++ b/ui/web/static/assets/suspects/scarlet_lark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:189851ecae664ca469e2f4bde92deda8fee139c44013c5389173bc565e2d23a2
+size 651046
diff --git a/ui/web/static/assets/suspects/thames_ghost.png b/ui/web/static/assets/suspects/thames_ghost.png
new file mode 100644
index 0000000000000000000000000000000000000000..2151dc34eaa63660bc55f3b0552ea7ea974aa87c
--- /dev/null
+++ b/ui/web/static/assets/suspects/thames_ghost.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d213cff26bf3d99398aa18a2a241cce8bdfb8abcded991448ee14b8e29afbc27
+size 634646
diff --git a/ui/web/static/assets/suspects/velvet_mask.png b/ui/web/static/assets/suspects/velvet_mask.png
new file mode 100644
index 0000000000000000000000000000000000000000..03d2ff0482a608021577d6051490a62f42709956
--- /dev/null
+++ b/ui/web/static/assets/suspects/velvet_mask.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dac8decaa5e59f6cd7ed962c54cd4f0f8e910db8f7d47cb3a7db2855f03625c0
+size 620360
diff --git a/ui/web/static/assets/suspects/violet_cipher.png b/ui/web/static/assets/suspects/violet_cipher.png
new file mode 100644
index 0000000000000000000000000000000000000000..76e527a90126daed4ef2c7b78dcd52042c42e9da
--- /dev/null
+++ b/ui/web/static/assets/suspects/violet_cipher.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:722a72606aded0c23bd47143a7076a4736e52f63dbdd0007778cb0f21e3e1153
+size 551246
diff --git a/ui/web/static/assets/suspects/white_raven.png b/ui/web/static/assets/suspects/white_raven.png
new file mode 100644
index 0000000000000000000000000000000000000000..50c7e3529526c46e9f046dce9273a36dd123d319
--- /dev/null
+++ b/ui/web/static/assets/suspects/white_raven.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fa975cb67350bc5119c88e4c9b18dddba84ce1510133fec6fb6539407a69c1ea
+size 563886
diff --git a/ui/web/static/assets/tutorial/01_board_overview.png b/ui/web/static/assets/tutorial/01_board_overview.png
new file mode 100644
index 0000000000000000000000000000000000000000..4802862daec7aa0148c09c681afb27422043be24
--- /dev/null
+++ b/ui/web/static/assets/tutorial/01_board_overview.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c8e7de571ef49b3a3ddc5201b9a93a6758babe010380414eada2a5fb317b4e12
+size 4245968
diff --git a/ui/web/static/assets/tutorial/02_briefing.png b/ui/web/static/assets/tutorial/02_briefing.png
new file mode 100644
index 0000000000000000000000000000000000000000..812d131f3528932399a0e3a0c74c86712d07bbba
--- /dev/null
+++ b/ui/web/static/assets/tutorial/02_briefing.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:14784757a764947790c2b735744b6320535482ec1a1157c596b185f004bbe7e9
+size 2803867
diff --git a/ui/web/static/assets/tutorial/03_map_layers.png b/ui/web/static/assets/tutorial/03_map_layers.png
new file mode 100644
index 0000000000000000000000000000000000000000..801207dc1545824abfcf9b5d8457e7125f3db0d9
--- /dev/null
+++ b/ui/web/static/assets/tutorial/03_map_layers.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a4227c6c80492b9d2ce145ed9f151fd2dc1069886ffea7fd34e0525765f9e031
+size 2745957
diff --git a/ui/web/static/assets/tutorial/04_tactics_tray.png b/ui/web/static/assets/tutorial/04_tactics_tray.png
new file mode 100644
index 0000000000000000000000000000000000000000..07fdc45d645709fd1c94d854395947b2c4a4f445
--- /dev/null
+++ b/ui/web/static/assets/tutorial/04_tactics_tray.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bfdc49f2c683003266257666e48aade54c8e0b829e09562fb5cb70c9747b0529
+size 138694
diff --git a/ui/web/static/assets/tutorial/06_notice.png b/ui/web/static/assets/tutorial/06_notice.png
new file mode 100644
index 0000000000000000000000000000000000000000..20c034cebb7672822b67340a5531bdb00c35a847
--- /dev/null
+++ b/ui/web/static/assets/tutorial/06_notice.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b602702aa8651372f06fb274aa4dc09a600ab72bb033b59515dd067357e4f2dd
+size 707146
diff --git a/ui/web/static/assets/tutorial/07_witness_interview.png b/ui/web/static/assets/tutorial/07_witness_interview.png
new file mode 100644
index 0000000000000000000000000000000000000000..9ba78327380faff2e7e3674edc081b2ca3a77f49
--- /dev/null
+++ b/ui/web/static/assets/tutorial/07_witness_interview.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e030a363f782f4219c196b1a4a60a1ad22b3861f61b8913d4a62ac5cfb116a65
+size 1581881
diff --git a/ui/web/static/assets/tutorial/08_witnesses_map.png b/ui/web/static/assets/tutorial/08_witnesses_map.png
new file mode 100644
index 0000000000000000000000000000000000000000..118a8a3e725fe7eaf9de17c975823183f4a4963e
--- /dev/null
+++ b/ui/web/static/assets/tutorial/08_witnesses_map.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fb575a4361a246d6d455bf2da0a6bd7989753a4d73b8d76fdc75752012a33afc
+size 2911885
diff --git a/ui/web/static/default-suspect.svg b/ui/web/static/default-suspect.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a0e0866328962f0c4c04688b766f2b93cb885829
--- /dev/null
+++ b/ui/web/static/default-suspect.svg
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/witness_panel.py b/ui/witness_panel.py
new file mode 100644
index 0000000000000000000000000000000000000000..715094cbbd21ccee43cef47970194be0fb90b593
--- /dev/null
+++ b/ui/witness_panel.py
@@ -0,0 +1,2 @@
+"""Witness response and questioning panels will live here."""
+