Joffrey Thomas commited on
Commit
7e8dd7f
·
1 Parent(s): ef225bc

New black/orange design, live traces with pictures, free-only default roster

Browse files

- Lobby, play, reveal and final screens follow the new reference design
- After lock-in the rivals feed shows each agent's views and tool results with images
- Only free models (Mistral Large) are open signed out; sign in or paste a key to unlock more
- /api/config exposes the traces dataset for the nav link

Files changed (7) hide show
  1. arena/agent.py +7 -2
  2. arena/config.py +16 -13
  3. arena/game.py +2 -2
  4. arena/main.py +7 -5
  5. static/app.js +114 -64
  6. static/index.html +73 -38
  7. static/style.css +258 -256
arena/agent.py CHANGED
@@ -44,6 +44,8 @@ async def run_agent(model_key: str, meta: dict, api_key: str, world: AgentWorld,
44
  intro = "Round start. Here is your first view. " + first.text
45
  chat.add_user(intro, first.image)
46
  trace.append({"role": "user", "text": intro, "image_ref": 0 if first.image else None})
 
 
47
  emit({"type": "agent_status", "agent": model_key, "status": "thinking", "step": 0})
48
 
49
  nudges, warned = 0, False
@@ -59,7 +61,7 @@ async def run_agent(model_key: str, meta: dict, api_key: str, world: AgentWorld,
59
  for c in turn.tool_calls]})
60
  # The game only forwards this once the human has locked in.
61
  emit({"type": "agent_thought", "agent": model_key, "step": world.steps,
62
- "text": turn.text, "reasoning": turn.reasoning,
63
  "tool_calls": [{"name": c.name, "args": c.args} for c in turn.tool_calls]})
64
 
65
  if not turn.tool_calls:
@@ -78,8 +80,11 @@ async def run_agent(model_key: str, meta: dict, api_key: str, world: AgentWorld,
78
  for call in turn.tool_calls:
79
  obs = await world.execute(call.name, call.args)
80
  results.append((call, obs))
 
81
  trace.append({"role": "tool", "name": call.name, "id": call.id, "text": obs.text,
82
- "image_ref": len(world.images) - 1 if obs.image else None})
 
 
83
  if obs.ticker:
84
  emit({"type": "agent_action", "agent": model_key, "step": world.steps,
85
  "max_steps": world.max_steps, "action": call.name, "text": obs.ticker})
 
44
  intro = "Round start. Here is your first view. " + first.text
45
  chat.add_user(intro, first.image)
46
  trace.append({"role": "user", "text": intro, "image_ref": 0 if first.image else None})
47
+ emit({"type": "agent_thought", "agent": model_key, "step": 0, "kind": "start",
48
+ "text": intro, "image_ref": 0 if first.image else None})
49
  emit({"type": "agent_status", "agent": model_key, "status": "thinking", "step": 0})
50
 
51
  nudges, warned = 0, False
 
61
  for c in turn.tool_calls]})
62
  # The game only forwards this once the human has locked in.
63
  emit({"type": "agent_thought", "agent": model_key, "step": world.steps,
64
+ "kind": "assistant", "text": turn.text, "reasoning": turn.reasoning,
65
  "tool_calls": [{"name": c.name, "args": c.args} for c in turn.tool_calls]})
66
 
67
  if not turn.tool_calls:
 
80
  for call in turn.tool_calls:
81
  obs = await world.execute(call.name, call.args)
82
  results.append((call, obs))
83
+ image_ref = len(world.images) - 1 if obs.image else None
84
  trace.append({"role": "tool", "name": call.name, "id": call.id, "text": obs.text,
85
+ "image_ref": image_ref})
86
+ emit({"type": "agent_thought", "agent": model_key, "step": world.steps,
87
+ "kind": "tool", "name": call.name, "text": obs.text, "image_ref": image_ref})
88
  if obs.ticker:
89
  emit({"type": "agent_action", "agent": model_key, "step": world.steps,
90
  "max_steps": world.max_steps, "action": call.name, "text": obs.ticker})
arena/config.py CHANGED
@@ -43,7 +43,9 @@ GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
43
  # `key` says where the credential comes from: an env var name, "byok" for
44
  # bring-your-own-key models the player unlocks with their own key, or "hf_login"
45
  # for models billed to the player's HF account via the Space's OAuth token.
46
- # `avatar` is a profile picture under static/avatars/. For BYOK models, `catalog`
 
 
47
  # names the provider whose model list the lobby dropdown is filled from with the
48
  # player's key; `model_id` is the default selection.
49
  # Optional `max_images`: cap on images per request for providers that limit it
@@ -54,62 +56,62 @@ MODELS = {
54
  label="Mistral Large 4", provider="openai_compat", base_url=MISTRAL_BASE_URL,
55
  model_id=os.getenv("MISTRAL_MODEL", "mistral-large-4-0-2"),
56
  key="MISTRAL_API_KEY", color="#FF8205", default_on=True,
57
- avatar="/static/avatars/mistral.webp",
58
  ),
59
  "mistral-medium": dict(
60
  label="Mistral Medium 3.5", provider="openai_compat", base_url=MISTRAL_BASE_URL,
61
  model_id=os.getenv("MISTRAL_MEDIUM_MODEL", "mistral-medium-latest"),
62
  key="MISTRAL_API_KEY", color="#FFAF00", default_on=False,
63
- avatar="/static/avatars/mistral.webp",
64
  ),
65
  "qwen": dict(
66
  label="Qwen3.8-27B", provider="openai_compat", base_url=HF_ROUTER_BASE_URL,
67
  model_id="Qwen/Qwen3.8-27B", key="hf_login", color="#615CED", default_on=False,
68
- avatar="/static/avatars/qwen.webp",
69
  ),
70
  "kimi": dict(
71
  label="Kimi-K3", provider="openai_compat", base_url=HF_ROUTER_BASE_URL,
72
  model_id="moonshotai/Kimi-K3", key="hf_login", color="#1F8FFF", default_on=False,
73
- avatar="/static/avatars/kimi.webp",
74
  ),
75
  "glm": dict(
76
  label="GLM-5.3-Flash", provider="openai_compat", base_url=HF_ROUTER_BASE_URL,
77
  model_id="zai-org/GLM-5.3-Flash", key="hf_login", color="#2BB673", default_on=False,
78
- avatar="/static/avatars/glm.webp",
79
  ),
80
  "gemma": dict(
81
  label="Gemma-4-31B", provider="openai_compat", base_url=HF_ROUTER_BASE_URL,
82
  model_id="google/gemma-4-31B-it", key="hf_login", color="#4C8DF6", default_on=False,
83
- avatar="/static/avatars/google.webp",
84
  ),
85
  "minimax": dict(
86
  label="MiniMax-M3", provider="openai_compat", base_url=HF_ROUTER_BASE_URL,
87
  model_id="MiniMaxAI/MiniMax-M3", key="hf_login", color="#F8514A", default_on=False,
88
- avatar="/static/avatars/minimax.webp",
89
  ),
90
  # Keys end up in DOM ids and CSS selectors: letters, digits, "-" and "_" only.
91
  "deepseek": dict(
92
  label="DeepSeek-V4.1-Flash", provider="openai_compat", base_url=HF_ROUTER_BASE_URL,
93
  model_id="deepseek-ai/DeepSeek-V4.1-Flash", key="hf_login", color="#4D6BFE", default_on=False,
94
- avatar="/static/avatars/deepseek.webp",
95
  ),
96
  "gpt": dict(
97
  label="GPT-6.1", provider="openai_responses",
98
  model_id=os.getenv("OPENAI_MODEL", "gpt-6.1-sol"), key="byok", key_hint="OpenAI API key",
99
  catalog="openai", color="#10A37F", default_on=False, reasoning_effort="medium",
100
- avatar="/static/avatars/openai.webp",
101
  ),
102
  "claude": dict(
103
  label="Claude Opus 5.5", provider="anthropic",
104
  model_id=os.getenv("ANTHROPIC_MODEL", "claude-opus-5-5"), key="byok",
105
  key_hint="Anthropic API key", catalog="anthropic", color="#D97757", default_on=False,
106
- avatar="/static/avatars/anthropic.webp",
107
  ),
108
  "gemini": dict(
109
- label="Gemini 3.1 Pro", provider="openai_compat", base_url=GEMINI_BASE_URL,
110
  model_id=os.getenv("GEMINI_MODEL", "gemini-3.8-flash"), key="byok",
111
  key_hint="Google AI Studio key", catalog="gemini", color="#A142F4", default_on=False,
112
- avatar="/static/avatars/google.webp",
113
  ),
114
  }
115
 
@@ -128,6 +130,7 @@ def public_models() -> list:
128
  hf_login = m["key"] == "hf_login"
129
  out.append(dict(
130
  key=key, label=m["label"], color=m["color"], avatar=m.get("avatar", ""),
 
131
  byok=byok, hf_login=hf_login, model_id=m["model_id"] if byok else "",
132
  key_hint=m.get("key_hint", ""), default_on=m["default_on"],
133
  available=byok or hf_login or bool(server_key_for(key)),
 
43
  # `key` says where the credential comes from: an env var name, "byok" for
44
  # bring-your-own-key models the player unlocks with their own key, or "hf_login"
45
  # for models billed to the player's HF account via the Space's OAuth token.
46
+ # `avatar` is a profile picture under static/avatars/, `org` the maker shown under
47
+ # the name. `free=True` models are open to everyone; every other model needs the
48
+ # player to sign in with Hugging Face (or paste their own key, for BYOK). For BYOK models, `catalog`
49
  # names the provider whose model list the lobby dropdown is filled from with the
50
  # player's key; `model_id` is the default selection.
51
  # Optional `max_images`: cap on images per request for providers that limit it
 
56
  label="Mistral Large 4", provider="openai_compat", base_url=MISTRAL_BASE_URL,
57
  model_id=os.getenv("MISTRAL_MODEL", "mistral-large-4-0-2"),
58
  key="MISTRAL_API_KEY", color="#FF8205", default_on=True,
59
+ avatar="/static/avatars/mistral.webp", org="Mistral", free=True,
60
  ),
61
  "mistral-medium": dict(
62
  label="Mistral Medium 3.5", provider="openai_compat", base_url=MISTRAL_BASE_URL,
63
  model_id=os.getenv("MISTRAL_MEDIUM_MODEL", "mistral-medium-latest"),
64
  key="MISTRAL_API_KEY", color="#FFAF00", default_on=False,
65
+ avatar="/static/avatars/mistral.webp", org="Mistral",
66
  ),
67
  "qwen": dict(
68
  label="Qwen3.8-27B", provider="openai_compat", base_url=HF_ROUTER_BASE_URL,
69
  model_id="Qwen/Qwen3.8-27B", key="hf_login", color="#615CED", default_on=False,
70
+ avatar="/static/avatars/qwen.webp", org="Qwen",
71
  ),
72
  "kimi": dict(
73
  label="Kimi-K3", provider="openai_compat", base_url=HF_ROUTER_BASE_URL,
74
  model_id="moonshotai/Kimi-K3", key="hf_login", color="#1F8FFF", default_on=False,
75
+ avatar="/static/avatars/kimi.webp", org="Moonshot AI",
76
  ),
77
  "glm": dict(
78
  label="GLM-5.3-Flash", provider="openai_compat", base_url=HF_ROUTER_BASE_URL,
79
  model_id="zai-org/GLM-5.3-Flash", key="hf_login", color="#2BB673", default_on=False,
80
+ avatar="/static/avatars/glm.webp", org="Z.ai",
81
  ),
82
  "gemma": dict(
83
  label="Gemma-4-31B", provider="openai_compat", base_url=HF_ROUTER_BASE_URL,
84
  model_id="google/gemma-4-31B-it", key="hf_login", color="#4C8DF6", default_on=False,
85
+ avatar="/static/avatars/google.webp", org="Google",
86
  ),
87
  "minimax": dict(
88
  label="MiniMax-M3", provider="openai_compat", base_url=HF_ROUTER_BASE_URL,
89
  model_id="MiniMaxAI/MiniMax-M3", key="hf_login", color="#F8514A", default_on=False,
90
+ avatar="/static/avatars/minimax.webp", org="MiniMax",
91
  ),
92
  # Keys end up in DOM ids and CSS selectors: letters, digits, "-" and "_" only.
93
  "deepseek": dict(
94
  label="DeepSeek-V4.1-Flash", provider="openai_compat", base_url=HF_ROUTER_BASE_URL,
95
  model_id="deepseek-ai/DeepSeek-V4.1-Flash", key="hf_login", color="#4D6BFE", default_on=False,
96
+ avatar="/static/avatars/deepseek.webp", org="DeepSeek",
97
  ),
98
  "gpt": dict(
99
  label="GPT-6.1", provider="openai_responses",
100
  model_id=os.getenv("OPENAI_MODEL", "gpt-6.1-sol"), key="byok", key_hint="OpenAI API key",
101
  catalog="openai", color="#10A37F", default_on=False, reasoning_effort="medium",
102
+ avatar="/static/avatars/openai.webp", org="OpenAI",
103
  ),
104
  "claude": dict(
105
  label="Claude Opus 5.5", provider="anthropic",
106
  model_id=os.getenv("ANTHROPIC_MODEL", "claude-opus-5-5"), key="byok",
107
  key_hint="Anthropic API key", catalog="anthropic", color="#D97757", default_on=False,
108
+ avatar="/static/avatars/anthropic.webp", org="Anthropic",
109
  ),
110
  "gemini": dict(
111
+ label="Gemini-3.8-flash", provider="openai_compat", base_url=GEMINI_BASE_URL,
112
  model_id=os.getenv("GEMINI_MODEL", "gemini-3.8-flash"), key="byok",
113
  key_hint="Google AI Studio key", catalog="gemini", color="#A142F4", default_on=False,
114
+ avatar="/static/avatars/google.webp", org="Google",
115
  ),
116
  }
117
 
 
130
  hf_login = m["key"] == "hf_login"
131
  out.append(dict(
132
  key=key, label=m["label"], color=m["color"], avatar=m.get("avatar", ""),
133
+ org=m.get("org", ""), free=bool(m.get("free")),
134
  byok=byok, hf_login=hf_login, model_id=m["model_id"] if byok else "",
135
  key_hint=m.get("key_hint", ""), default_on=m["default_on"],
136
  available=byok or hf_login or bool(server_key_for(key)),
arena/game.py CHANGED
@@ -202,11 +202,11 @@ class Game:
202
  return rows
203
 
204
  def image(self, round_number: int, agent: str, idx: int) -> Optional[bytes]:
205
- """Agent-view image for the replay -- only once that round is revealed."""
206
  if not 1 <= round_number <= len(self.rounds):
207
  return None
208
  rnd = self.rounds[round_number - 1]
209
- if rnd.reveal is None or agent not in rnd.worlds:
210
  return None
211
  images = rnd.worlds[agent].images
212
  return images[idx]["bytes"] if 0 <= idx < len(images) else None
 
202
  return rows
203
 
204
  def image(self, round_number: int, agent: str, idx: int) -> Optional[bytes]:
205
+ """What an agent saw -- only once the human has locked in (or the round is over)."""
206
  if not 1 <= round_number <= len(self.rounds):
207
  return None
208
  rnd = self.rounds[round_number - 1]
209
+ if not self.thoughts_unlocked(rnd) or agent not in rnd.worlds:
210
  return None
211
  images = rnd.worlds[agent].images
212
  return images[idx]["bytes"] if 0 <= idx < len(images) else None
arena/main.py CHANGED
@@ -15,7 +15,8 @@ from pydantic import BaseModel, Field
15
 
16
  from . import catalog, streetview
17
  from .config import (GAMES_PER_IP_PER_HOUR, GOOGLE_MAPS_BROWSER_KEY, MODELS, ROUND_SECONDS,
18
- ROUNDS_PER_GAME, AGENT_MAX_STEPS, AGENT_MAX_GEOCODES, public_models, server_key_for)
 
19
  from .game import GAMES, Game, prune_games
20
 
21
  mimetypes.add_type("image/webp", ".webp") # missing from some slim images
@@ -75,7 +76,7 @@ def config(request: Request):
75
  return {"models": public_models(), "modes": streetview.location_modes(),
76
  "rounds": ROUNDS_PER_GAME, "round_seconds": ROUND_SECONDS,
77
  "max_steps": AGENT_MAX_STEPS, "max_geocodes": AGENT_MAX_GEOCODES, "maps_key": GOOGLE_MAPS_BROWSER_KEY,
78
- "hf_user": user}
79
 
80
 
81
  class NewGame(BaseModel):
@@ -90,8 +91,9 @@ class NewGame(BaseModel):
90
  def create_game(body: NewGame, request: Request):
91
  prune_games()
92
  oauth = _hf_login(request)
93
- if not oauth and any(MODELS.get(k, {}).get("key") == "hf_login" for k in body.models):
94
- raise HTTPException(401, "sign in with Hugging Face to play the open models")
 
95
  byok = {k: v.strip() for k, v in body.byok.items() if MODELS.get(k, {}).get("key") == "byok"}
96
  chosen = []
97
  for key in body.models:
@@ -206,5 +208,5 @@ async def events(game_id: str, request: Request):
206
  def agent_image(game_id: str, round_number: int, agent: str, idx: int):
207
  data = _game(game_id).image(round_number, agent, idx)
208
  if data is None:
209
- raise HTTPException(404, "image unavailable (round not revealed yet?)")
210
  return Response(data, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=3600"})
 
15
 
16
  from . import catalog, streetview
17
  from .config import (GAMES_PER_IP_PER_HOUR, GOOGLE_MAPS_BROWSER_KEY, MODELS, ROUND_SECONDS,
18
+ ROUNDS_PER_GAME, TRACES_REPO, AGENT_MAX_STEPS, AGENT_MAX_GEOCODES, public_models,
19
+ server_key_for)
20
  from .game import GAMES, Game, prune_games
21
 
22
  mimetypes.add_type("image/webp", ".webp") # missing from some slim images
 
76
  return {"models": public_models(), "modes": streetview.location_modes(),
77
  "rounds": ROUNDS_PER_GAME, "round_seconds": ROUND_SECONDS,
78
  "max_steps": AGENT_MAX_STEPS, "max_geocodes": AGENT_MAX_GEOCODES, "maps_key": GOOGLE_MAPS_BROWSER_KEY,
79
+ "hf_user": user, "traces_repo": TRACES_REPO}
80
 
81
 
82
  class NewGame(BaseModel):
 
91
  def create_game(body: NewGame, request: Request):
92
  prune_games()
93
  oauth = _hf_login(request)
94
+ if not oauth and any(k in MODELS and MODELS[k]["key"] != "byok" and not MODELS[k].get("free")
95
+ for k in body.models):
96
+ raise HTTPException(401, "sign in with Hugging Face to unlock more opponents")
97
  byok = {k: v.strip() for k, v in body.byok.items() if MODELS.get(k, {}).get("key") == "byok"}
98
  chosen = []
99
  for key in body.models:
 
208
  def agent_image(game_id: str, round_number: int, agent: str, idx: int):
209
  data = _game(game_id).image(round_number, agent, idx)
210
  if data is None:
211
+ raise HTTPException(404, "image unavailable (lock in your own guess first)")
212
  return Response(data, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=3600"})
static/app.js CHANGED
@@ -1,7 +1,7 @@
1
  "use strict";
2
 
3
  const HUMAN = "you";
4
- const HUMAN_COLOR = "#ffc53d";
5
  const ACTION_ICONS = {
6
  look: "👀", pan: "↻", zoom: "🔍", move: "🚶", view_map: "🗺️",
7
  place_pin: "📍", reverse_geocode: "🔎", submit_guess: "🔒",
@@ -84,39 +84,66 @@ function dotIcon(color, scale = 9) {
84
  }
85
 
86
  /* ================= Lobby ================= */
 
 
87
  async function initLobby() {
88
  state.cfg = await api("/api/config");
89
  loadMaps(state.cfg.maps_key).catch((e) => ($("#lobby-error").textContent = e.message));
90
  const hfUser = state.cfg.hf_user;
91
- $("#hf-auth").innerHTML = hfUser
92
- ? `<div class="hf-user"><img src="${esc(hfUser.avatar)}" alt=""><span>Signed in as <b>@${esc(hfUser.username)}</b></span></div>
93
- <a class="hf-out" href="/oauth/huggingface/logout">Sign out</a>`
94
- : `<a id="hf-login" class="btn hf" href="/oauth/huggingface/login">🤗 Sign in with Hugging Face</a>
95
- <div class="hint">Required for the open models. Inference runs on your HF account.</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  const names = (pred) => state.cfg.models.filter(pred).map((m) => esc(m.label));
97
  const list = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}` : xs[0];
98
  const hfModels = names((m) => m.hf_login), byokModels = names((m) => m.byok);
99
  $("#key-hint").innerHTML = [
100
- hfModels.length && `🤗 ${list(hfModels)} run on <b>your Hugging Face account</b> once you sign in.`,
101
- byokModels.length && `🔑 ${list(byokModels)} play on <b>your own key</b>.`,
102
  (hfModels.length || byokModels.length) && "Keys and tokens are kept in memory for the game only and never logged.",
103
  ].filter(Boolean).join(" ");
 
104
  const nameInput = $("#player-name");
105
- const syncAvatar = () => ($("#player-avatar").textContent = (nameInput.value.trim()[0] || "?").toUpperCase());
 
 
 
 
 
 
106
  nameInput.value = localStorage.getItem("arena.name") || hfUser?.username || "";
107
  nameInput.oninput = syncAvatar;
 
108
  syncAvatar();
109
- const mins = Math.round(state.cfg.round_seconds / 60 * 10) / 10;
110
- $("#rules-hint").innerHTML =
111
- `<span class="chip"><b>${state.cfg.rounds}</b> rounds</span>` +
112
- `<span class="chip"><b>${mins}</b> min each</span>` +
113
- `<span class="chip" title="look, pan, zoom, move, map, pin"><b>${state.cfg.max_steps}</b> AI moves / round</span>` +
114
- `<span class="chip" title="text-only place lookups, on top of the moves"><b>+${state.cfg.max_geocodes}</b> geocodes</span>`;
115
 
116
  const modes = $("#mode-picker");
117
  state.cfg.modes.forEach((m) => {
118
  const b = document.createElement("button");
119
- b.textContent = m === "world" ? "🌐 World" : `🏙️ ${m}`;
120
  b.className = m === state.mode ? "on" : "";
121
  b.onclick = () => {
122
  state.mode = m;
@@ -126,43 +153,55 @@ async function initLobby() {
126
  });
127
 
128
  const grid = $("#model-grid");
129
- const syncCount = () => {
130
- const n = state.selected.size;
131
- $("#opp-count").textContent = n ? `${n} selected` : "pick at least one";
132
- };
133
  state.cfg.models.forEach((m) => {
134
  const el = document.createElement("div");
135
- const needsLogin = m.hf_login && !hfUser;
136
- el.className = "fighter" + (m.available ? "" : " disabled") + (m.byok ? " byok" : "")
137
- + (m.hf_login ? " hf" : "") + (needsLogin ? " locked" : "");
 
138
  el.style.setProperty("--c", m.color);
139
- const tag = m.byok ? "🔑 your key" : needsLogin ? "🤗 sign in" : m.hf_login ? "🤗 your HF" : m.available ? "free" : "offline";
140
- el.innerHTML = `<span class="check">✓</span>${avatarHtml(m.label, m.color, "lg", false, m.avatar)}
141
- <div class="fname">${esc(m.label)}</div><div class="ftag">${tag}</div>`;
 
 
 
 
 
 
 
 
142
  if (m.byok) {
 
 
143
  const input = document.createElement("input");
144
- input.type = "password"; input.placeholder = "paste key"; input.title = m.key_hint; input.autocomplete = "off";
 
145
  const picker = byokPicker(m, el);
 
 
 
 
146
  input.onclick = (e) => e.stopPropagation();
147
  input.oninput = () => {
148
  state.byok[m.key] = input.value.trim();
 
149
  toggle(!!state.byok[m.key]);
150
  picker.refresh(state.byok[m.key]);
151
  };
152
- el.append(input, picker.select);
153
  }
154
- const toggle = (on) => {
155
- on ? state.selected.add(m.key) : state.selected.delete(m.key);
156
- el.classList.toggle("on", on);
157
- syncCount();
158
- };
159
  el.onclick = () => {
160
- if (!m.available) return;
161
- if (m.byok && !state.byok[m.key]) { el.querySelector("input").focus(); return; }
162
  if (needsLogin) { nudgeLogin(); return; }
163
  toggle(!state.selected.has(m.key));
164
  };
165
- if (m.default_on && m.available && !m.byok && !needsLogin) toggle(true);
 
166
  grid.appendChild(el);
167
  });
168
  syncCount();
@@ -177,6 +216,7 @@ function byokPicker(m, tile) {
177
  select.className = "hidden"; select.title = "Model";
178
  select.onclick = (e) => e.stopPropagation();
179
  select.onchange = () => (state.byokModel[m.key] = select.value);
 
180
  const setOptions = (models) => {
181
  if (!models.some((x) => x.id === m.model_id)) models.unshift({ id: m.model_id, label: m.model_id });
182
  select.innerHTML = models.map((x) =>
@@ -186,7 +226,7 @@ function byokPicker(m, tile) {
186
  let timer, seq = 0;
187
  const refresh = (key) => {
188
  clearTimeout(timer);
189
- tile.querySelector(".ftag").textContent = "🔑 your key";
190
  if (!key) { select.classList.add("hidden"); delete state.byokModel[m.key]; return; }
191
  const mine = ++seq;
192
  timer = setTimeout(async () => {
@@ -200,7 +240,7 @@ function byokPicker(m, tile) {
200
  // Keep the default model playable; just say the list could not be loaded.
201
  select.classList.add("hidden");
202
  state.byokModel[m.key] = m.model_id;
203
- tile.querySelector(".ftag").textContent = `⚠ ${e.message}`;
204
  }
205
  }, 600);
206
  };
@@ -209,7 +249,6 @@ function byokPicker(m, tile) {
209
 
210
  function nudgeLogin() {
211
  const btn = $("#hf-login");
212
- if (!btn) return;
213
  btn.classList.remove("nudge"); void btn.offsetWidth; btn.classList.add("nudge");
214
  btn.scrollIntoView({ behavior: "smooth", block: "center" });
215
  }
@@ -300,12 +339,12 @@ function startRound(rnd) {
300
  state.guessMarker?.setMap(null);
301
  state.guessMarker = null;
302
  $("#guess-btn").disabled = true;
303
- $("#guess-btn").textContent = "Place your pin";
304
  $("#guess-btn").onclick = submitGuess;
305
 
306
  renderRivals(rnd.agents);
307
  $("#rivals").classList.remove("unlocked");
308
- $("#rivals .live").textContent = "LIVE · BLIND";
309
  if (rnd.human_locked) unlockThoughts();
310
  clearInterval(state.timerId);
311
  state.timerId = setInterval(tick, 250);
@@ -368,16 +407,16 @@ function renderRivals(agents) {
368
  const el = document.createElement("div");
369
  el.className = "rival"; el.id = `rival-${m.key}`;
370
  el.style.setProperty("--c", m.color);
371
- el.innerHTML = `<div class="row">${avatarHtml(m.label, m.color, "sm", false, m.avatar)}${esc(m.label)}
372
  <span class="status ${a.status}">${statusText(a.status)}</span></div>
373
  <div class="bar"><i style="width:${(100 * a.step) / state.cfg.max_steps}%"></i></div>
374
- <div class="last">${a.status === "locked" ? "🔒 locked in a guess" : "🧭 getting its bearings"}</div>
375
  <div class="feed"></div>`;
376
  list.appendChild(el);
377
  });
378
  }
379
  function statusText(s) {
380
- return { thinking: "thinking", locked: "locked in", out: "no guess", error: "crashed" }[s] || s;
381
  }
382
 
383
  function onAgentAction(ev) {
@@ -385,7 +424,7 @@ function onAgentAction(ev) {
385
  if (!el) return;
386
  el.querySelector(".bar > i").style.width = `${(100 * ev.step) / ev.max_steps}%`;
387
  const last = el.querySelector(".last");
388
- last.textContent = `${ACTION_ICONS[ev.action] || "•"} ${ev.text} · step ${ev.step}/${ev.max_steps}`;
389
  last.classList.remove("flash"); void last.offsetWidth; last.classList.add("flash");
390
  }
391
 
@@ -398,11 +437,11 @@ function onAgentStatus(ev) {
398
  if (ev.status === "locked") {
399
  const left = Math.max(0, state.round.deadline_epoch - (Date.now() / 1000 + state.clockOffset));
400
  const used = state.cfg.round_seconds - left;
401
- el.querySelector(".last").textContent = `🔒 locked in after ${ev.step} steps · ${Math.round(used)}s`;
402
  } else if (ev.status === "error") {
403
- el.querySelector(".last").textContent = "💥 the API call failed";
404
  } else if (ev.status === "out") {
405
- el.querySelector(".last").textContent = "⌛ ran out without a guess";
406
  }
407
  updateWaiting();
408
  }
@@ -415,7 +454,7 @@ async function unlockThoughts(retry = true) {
415
  rnd.thoughtsOpen = true;
416
  rnd.seenThoughts = new Set();
417
  $("#rivals").classList.add("unlocked");
418
- $("#rivals .live").textContent = "LIVE · UNLOCKED";
419
  try {
420
  const r = await api(`/api/games/${state.game.game_id}/thoughts`);
421
  if (state.round === rnd) r.thoughts.forEach(onAgentThought);
@@ -435,9 +474,14 @@ function onAgentThought(ev) {
435
  const feed = $(`#rival-${ev.agent} .feed`);
436
  if (!feed) return;
437
  const el = document.createElement("div");
438
- el.className = "thought"; el.dataset.seq = ev.seq;
439
- el.innerHTML = `${ev.reasoning ? `<details><summary>thinking</summary><div class="thinking">${esc(ev.reasoning)}</div></details>` : ""}
440
- ${ev.text ? `<div class="text">${esc(ev.text)}</div>` : ""}${callChips(ev.tool_calls)}`;
 
 
 
 
 
441
  // Backlog and live events can interleave: keep each feed in emission order.
442
  const after = [...feed.children].find((c) => +c.dataset.seq > ev.seq);
443
  const stick = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 30;
@@ -445,6 +489,15 @@ function onAgentThought(ev) {
445
  if (stick) feed.scrollTop = feed.scrollHeight;
446
  }
447
 
 
 
 
 
 
 
 
 
 
448
  function callChips(calls) {
449
  return (calls || []).map((c) =>
450
  `<span class="call">${ACTION_ICONS[c.name] || ""} ${esc(c.name)}(${esc(Object.entries(c.args || {})
@@ -478,7 +531,7 @@ function onReveal(rev) {
478
  $("#round-table").classList.remove("hidden");
479
  renderRoundTable(rev);
480
  drawRevealMap(rev);
481
- $("#next-btn").textContent = rev.game_over ? "Final results 🏆" : "Next round →";
482
  $("#next-btn").onclick = rev.game_over ? showFinal : nextRound;
483
  }
484
 
@@ -493,11 +546,11 @@ function renderRoundTable(rev) {
493
  <td><div class="who">${avatarOf(key)}<div>${esc(labelOf(key))}
494
  <div class="sub">${fmtKm(p.distance_km)} · ${sub}</div></div></div></td>
495
  <td class="pts">${fmtPts(p.score)}<div class="sub">Σ ${fmtPts(rev.totals[key])}</div></td>
496
- <td class="chev">${key === HUMAN ? "" : "›"}</td></tr>`;
497
  }).join("");
498
  $("#round-table").innerHTML = `<table class="lb"><thead><tr><th>#</th><th>Player</th>
499
  <th style="text-align:right">Points</th><th></th></tr></thead><tbody>${rows}</tbody></table>
500
- <p class="hint" style="padding:0 8px">Click an AI to replay everything it saw, thought and did.</p>`;
501
  $("#round-table").querySelectorAll("tr.clickable").forEach((tr) =>
502
  tr.addEventListener("click", () => openReplay(tr.dataset.key)));
503
  }
@@ -544,8 +597,7 @@ function openReplay(key) {
544
  const p = rev.participants[key];
545
  if (!p?.trace) return;
546
  const color = colorOf(key);
547
- const img = (ref) => ref == null ? "" :
548
- `<img loading="lazy" src="/api/games/${state.game.game_id}/rounds/${state.round.round}/images/${key}/${ref}" alt="">`;
549
 
550
  let html = `<div class="verdict" style="--c:${color}">
551
  ${p.guess ? `Guessed <b>${esc(p.guess.country || `${p.guess.lat.toFixed(3)}, ${p.guess.lng.toFixed(3)}`)}</b>:
@@ -557,23 +609,21 @@ function openReplay(key) {
557
 
558
  for (const e of p.trace) {
559
  if (e.role === "user") {
560
- html += `<div class="step"><div class="label">Game</div><div class="text">${esc(e.text)}</div>${img(e.image_ref)}</div>`;
561
  } else if (e.role === "assistant") {
562
  const calls = callChips(e.tool_calls);
563
- html += `<div class="step" style="--c:${color}"><div class="label">${esc(labelOf(key))}</div>
564
  ${e.text ? `<div class="text">${esc(e.text)}</div>` : ""}
565
  ${e.reasoning ? `<details><summary>thinking</summary><div class="thinking">${esc(e.reasoning)}</div></details>` : ""}
566
  ${calls}</div>`;
567
  } else if (e.role === "tool") {
568
- html += `<div class="step"><div class="label">${ACTION_ICONS[e.name] || ""} ${esc(e.name)} result</div>
569
  <div class="text muted">${esc(e.text)}</div>${img(e.image_ref)}</div>`;
570
  }
571
  }
572
  $("#replay-title").innerHTML = `${avatarOf(key, "sm")} ${esc(labelOf(key))}`;
573
  $("#replay-body").innerHTML = html;
574
- $("#replay-body").querySelectorAll("img").forEach((im) => im.addEventListener("click", () => {
575
- $("#lightbox img").src = im.src; $("#lightbox").classList.remove("hidden");
576
- }));
577
  $("#round-table").classList.add("hidden");
578
  $("#replay").classList.remove("hidden");
579
  $("#replay").scrollTop = 0;
@@ -588,7 +638,7 @@ function showFinal() {
588
  const myRank = order.indexOf(HUMAN) + 1;
589
  const beaten = order.length - myRank;
590
  const machines = order.length - 1;
591
- $("#final-title").textContent = myRank === 1 ? "🏆 Humanity prevails!" : myRank === order.length ? "🤖 The machines win" : `You finished #${myRank}`;
592
  $("#final-sub").textContent = `You beat ${beaten} of ${machines} AI opponent${machines > 1 ? "s" : ""} with ${fmtPts(totals[HUMAN])} points.`;
593
 
594
  const podiumOrder = [1, 0, 2].filter((i) => i < order.length);
 
1
  "use strict";
2
 
3
  const HUMAN = "you";
4
+ const HUMAN_COLOR = "#ff5a14";
5
  const ACTION_ICONS = {
6
  look: "👀", pan: "↻", zoom: "🔍", move: "🚶", view_map: "🗺️",
7
  place_pin: "📍", reverse_geocode: "🔎", submit_guess: "🔒",
 
84
  }
85
 
86
  /* ================= Lobby ================= */
87
+ const svgIcon = (id, cls = "i") => `<svg class="${cls}"><use href="#${id}"/></svg>`;
88
+
89
  async function initLobby() {
90
  state.cfg = await api("/api/config");
91
  loadMaps(state.cfg.maps_key).catch((e) => ($("#lobby-error").textContent = e.message));
92
  const hfUser = state.cfg.hf_user;
93
+
94
+ $("#nav-auth").innerHTML = hfUser
95
+ ? `<span class="nav-user">@${esc(hfUser.username)}</span>`
96
+ : `<a class="link accent" href="/oauth/huggingface/login">Sign in</a>`;
97
+ $("#sign-out-slot").innerHTML = hfUser ? `<a class="link muted" href="/oauth/huggingface/logout">Sign out</a>` : "";
98
+ if (state.cfg.traces_repo) $("#nav-traces").href = `https://huggingface.co/datasets/${state.cfg.traces_repo}`;
99
+ else $("#nav-traces").classList.add("hidden");
100
+ $("#hf-login").classList.toggle("hidden", !!hfUser);
101
+ $("#login-note").classList.toggle("hidden", !!hfUser);
102
+
103
+ const mins = Math.round(state.cfg.round_seconds / 60 * 10) / 10;
104
+ $("#rules-body").innerHTML = `<ul>
105
+ <li>${state.cfg.rounds} rounds, ${mins} minutes each. Everyone gets dropped at the same Street View spot.</li>
106
+ <li>The AIs play with tools: look, pan, zoom, move, a map, a pin, and up to ${state.cfg.max_steps} moves per round
107
+ plus ${state.cfg.max_geocodes} text-only place lookups.</li>
108
+ <li>While you play, their feed is blind: you see what they do, not what they see.
109
+ Lock in your guess and their reasoning and pictures go live.</li>
110
+ <li>Up to 5,000 points per round, decaying with distance. After each round, click an AI to replay its full trace.</li>
111
+ <li>Mistral Large is free for everyone. Sign in with Hugging Face for the open models (inference on your HF account),
112
+ or paste your own key for the closed ones. Keys stay in memory for the game only and are never logged.</li></ul>`;
113
+ $("#nav-rules").onclick = () => $("#rules-modal").classList.remove("hidden");
114
+ $("#rules-modal").onclick = (e) => { if (e.target.id === "rules-modal") $("#rules-modal").classList.add("hidden"); };
115
+
116
  const names = (pred) => state.cfg.models.filter(pred).map((m) => esc(m.label));
117
  const list = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}` : xs[0];
118
  const hfModels = names((m) => m.hf_login), byokModels = names((m) => m.byok);
119
  $("#key-hint").innerHTML = [
120
+ hfModels.length && `${list(hfModels)} run on your Hugging Face account once you sign in.`,
121
+ byokModels.length && `${list(byokModels)} play on your own key.`,
122
  (hfModels.length || byokModels.length) && "Keys and tokens are kept in memory for the game only and never logged.",
123
  ].filter(Boolean).join(" ");
124
+
125
  const nameInput = $("#player-name");
126
+ const syncAvatar = () => {
127
+ if (!hfUser?.avatar) $("#player-avatar").textContent = (nameInput.value.trim()[0] || "?").toUpperCase();
128
+ };
129
+ if (hfUser?.avatar) {
130
+ $("#player-avatar").classList.add("pic");
131
+ $("#player-avatar").innerHTML = `<img src="${esc(hfUser.avatar)}" alt="">`;
132
+ }
133
  nameInput.value = localStorage.getItem("arena.name") || hfUser?.username || "";
134
  nameInput.oninput = syncAvatar;
135
+ $("#name-change").onclick = () => { nameInput.focus(); nameInput.select(); };
136
  syncAvatar();
137
+
138
+ const stat = (n, label, title = "") => `<div class="stat" title="${title}"><b>${n}</b><small>${label}</small></div>`;
139
+ $("#rules-hint").innerHTML = stat(state.cfg.rounds, "Rounds") + stat(mins, "Min each")
140
+ + stat(state.cfg.max_steps, "AI moves", "look, pan, zoom, move, map, pin")
141
+ + stat(`+${state.cfg.max_geocodes}`, "Geocodes", "text-only place lookups, on top of the moves");
 
142
 
143
  const modes = $("#mode-picker");
144
  state.cfg.modes.forEach((m) => {
145
  const b = document.createElement("button");
146
+ b.innerHTML = m === "world" ? `${svgIcon("i-globe", "")} World` : esc(m);
147
  b.className = m === state.mode ? "on" : "";
148
  b.onclick = () => {
149
  state.mode = m;
 
153
  });
154
 
155
  const grid = $("#model-grid");
156
+ const total = state.cfg.models.length;
157
+ const syncCount = () => ($("#opp-count").textContent = `${state.selected.size} / ${total} Selected`);
 
 
158
  state.cfg.models.forEach((m) => {
159
  const el = document.createElement("div");
160
+ // Free models are open to everyone; the rest need a Hugging Face login or the player's key.
161
+ const needsLogin = !m.byok && !m.free && !hfUser;
162
+ const offline = !m.available;
163
+ el.className = "fighter";
164
  el.style.setProperty("--c", m.color);
165
+ const tag = offline ? "Offline" : m.hf_login ? "Your HF" : "Free";
166
+ el.innerHTML = `<span class="mark">${svgIcon("i-check", "")}</span><svg class="lock"><use href="#i-lock"/></svg>
167
+ ${avatarHtml(m.label, m.color, "lg", false, m.avatar)}
168
+ <div class="fname">${esc(m.label)}</div><div class="org">${esc(m.org || "")}</div>
169
+ ${m.byok ? `<div class="keyline">Your Key</div>` : `<div class="tag">${tag}</div>`}`;
170
+ const setLocked = (locked) => el.classList.toggle("locked", locked);
171
+ const toggle = (on) => {
172
+ on ? state.selected.add(m.key) : state.selected.delete(m.key);
173
+ el.classList.toggle("on", on);
174
+ syncCount();
175
+ };
176
  if (m.byok) {
177
+ const paste = document.createElement("button");
178
+ paste.className = "paste"; paste.textContent = "Paste key";
179
  const input = document.createElement("input");
180
+ input.type = "password"; input.placeholder = m.key_hint || "API key"; input.autocomplete = "off";
181
+ input.className = "hidden";
182
  const picker = byokPicker(m, el);
183
+ paste.onclick = (e) => {
184
+ e.stopPropagation();
185
+ paste.classList.add("hidden"); input.classList.remove("hidden"); input.focus();
186
+ };
187
  input.onclick = (e) => e.stopPropagation();
188
  input.oninput = () => {
189
  state.byok[m.key] = input.value.trim();
190
+ setLocked(!state.byok[m.key]);
191
  toggle(!!state.byok[m.key]);
192
  picker.refresh(state.byok[m.key]);
193
  };
194
+ el.append(paste, input, picker.select);
195
  }
196
+ setLocked(m.byok || needsLogin || offline);
 
 
 
 
197
  el.onclick = () => {
198
+ if (offline) return;
199
+ if (m.byok && !state.byok[m.key]) { el.querySelector(".paste:not(.hidden)")?.click(); el.querySelector("input").focus(); return; }
200
  if (needsLogin) { nudgeLogin(); return; }
201
  toggle(!state.selected.has(m.key));
202
  };
203
+ // Only the free model starts selected; everything else is opt-in.
204
+ if (m.default_on && m.free && !offline) toggle(true);
205
  grid.appendChild(el);
206
  });
207
  syncCount();
 
216
  select.className = "hidden"; select.title = "Model";
217
  select.onclick = (e) => e.stopPropagation();
218
  select.onchange = () => (state.byokModel[m.key] = select.value);
219
+ const keyline = tile.querySelector(".keyline");
220
  const setOptions = (models) => {
221
  if (!models.some((x) => x.id === m.model_id)) models.unshift({ id: m.model_id, label: m.model_id });
222
  select.innerHTML = models.map((x) =>
 
226
  let timer, seq = 0;
227
  const refresh = (key) => {
228
  clearTimeout(timer);
229
+ keyline.textContent = "Your Key";
230
  if (!key) { select.classList.add("hidden"); delete state.byokModel[m.key]; return; }
231
  const mine = ++seq;
232
  timer = setTimeout(async () => {
 
240
  // Keep the default model playable; just say the list could not be loaded.
241
  select.classList.add("hidden");
242
  state.byokModel[m.key] = m.model_id;
243
+ keyline.textContent = `⚠ ${e.message}`;
244
  }
245
  }, 600);
246
  };
 
249
 
250
  function nudgeLogin() {
251
  const btn = $("#hf-login");
 
252
  btn.classList.remove("nudge"); void btn.offsetWidth; btn.classList.add("nudge");
253
  btn.scrollIntoView({ behavior: "smooth", block: "center" });
254
  }
 
339
  state.guessMarker?.setMap(null);
340
  state.guessMarker = null;
341
  $("#guess-btn").disabled = true;
342
+ $("#guess-btn").textContent = "Place your Pin";
343
  $("#guess-btn").onclick = submitGuess;
344
 
345
  renderRivals(rnd.agents);
346
  $("#rivals").classList.remove("unlocked");
347
+ $("#live-mode").textContent = "Blind";
348
  if (rnd.human_locked) unlockThoughts();
349
  clearInterval(state.timerId);
350
  state.timerId = setInterval(tick, 250);
 
407
  const el = document.createElement("div");
408
  el.className = "rival"; el.id = `rival-${m.key}`;
409
  el.style.setProperty("--c", m.color);
410
+ el.innerHTML = `<div class="row">${avatarHtml(m.label, m.color, "", false, m.avatar)}<span class="nm">${esc(m.label)}</span>
411
  <span class="status ${a.status}">${statusText(a.status)}</span></div>
412
  <div class="bar"><i style="width:${(100 * a.step) / state.cfg.max_steps}%"></i></div>
413
+ <div class="last">${svgIcon("i-info")}<span>${a.status === "locked" ? "Locked in a guess" : "Getting its bearings"}</span></div>
414
  <div class="feed"></div>`;
415
  list.appendChild(el);
416
  });
417
  }
418
  function statusText(s) {
419
+ return { thinking: "Thinking", locked: "Locked in", out: "No guess", error: "Crashed" }[s] || s;
420
  }
421
 
422
  function onAgentAction(ev) {
 
424
  if (!el) return;
425
  el.querySelector(".bar > i").style.width = `${(100 * ev.step) / ev.max_steps}%`;
426
  const last = el.querySelector(".last");
427
+ last.querySelector("span").textContent = `${ev.text} · step ${ev.step}/${ev.max_steps}`;
428
  last.classList.remove("flash"); void last.offsetWidth; last.classList.add("flash");
429
  }
430
 
 
437
  if (ev.status === "locked") {
438
  const left = Math.max(0, state.round.deadline_epoch - (Date.now() / 1000 + state.clockOffset));
439
  const used = state.cfg.round_seconds - left;
440
+ el.querySelector(".last span").textContent = `Locked in after ${ev.step} steps · ${Math.round(used)}s`;
441
  } else if (ev.status === "error") {
442
+ el.querySelector(".last span").textContent = "The API call failed";
443
  } else if (ev.status === "out") {
444
+ el.querySelector(".last span").textContent = "Ran out without a guess";
445
  }
446
  updateWaiting();
447
  }
 
454
  rnd.thoughtsOpen = true;
455
  rnd.seenThoughts = new Set();
456
  $("#rivals").classList.add("unlocked");
457
+ $("#live-mode").textContent = "Unlocked";
458
  try {
459
  const r = await api(`/api/games/${state.game.game_id}/thoughts`);
460
  if (state.round === rnd) r.thoughts.forEach(onAgentThought);
 
474
  const feed = $(`#rival-${ev.agent} .feed`);
475
  if (!feed) return;
476
  const el = document.createElement("div");
477
+ const kind = ev.kind || "assistant";
478
+ el.className = `thought ${kind}`; el.dataset.seq = ev.seq;
479
+ const title = kind === "start" ? "Round start" : kind === "tool" ? `${ACTION_ICONS[ev.name] || ""} ${esc(ev.name)}` : "";
480
+ el.innerHTML = `${title ? `<div class="tl">${title}</div>` : ""}
481
+ ${ev.reasoning ? `<details><summary>thinking</summary><div class="thinking">${esc(ev.reasoning)}</div></details>` : ""}
482
+ ${ev.text ? `<div class="text">${esc(ev.text)}</div>` : ""}${callChips(ev.tool_calls)}
483
+ ${traceImg(ev.agent, rnd.round, ev.image_ref)}`;
484
+ el.querySelector("img")?.addEventListener("click", (e) => openLightbox(e.target.src));
485
  // Backlog and live events can interleave: keep each feed in emission order.
486
  const after = [...feed.children].find((c) => +c.dataset.seq > ev.seq);
487
  const stick = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 30;
 
489
  if (stick) feed.scrollTop = feed.scrollHeight;
490
  }
491
 
492
+ function traceImg(agent, round, ref) {
493
+ return ref == null ? "" :
494
+ `<img loading="lazy" src="/api/games/${state.game.game_id}/rounds/${round}/images/${agent}/${ref}" alt="">`;
495
+ }
496
+ function openLightbox(src) {
497
+ $("#lightbox img").src = src;
498
+ $("#lightbox").classList.remove("hidden");
499
+ }
500
+
501
  function callChips(calls) {
502
  return (calls || []).map((c) =>
503
  `<span class="call">${ACTION_ICONS[c.name] || ""} ${esc(c.name)}(${esc(Object.entries(c.args || {})
 
531
  $("#round-table").classList.remove("hidden");
532
  renderRoundTable(rev);
533
  drawRevealMap(rev);
534
+ $("#next-btn").textContent = rev.game_over ? "Final results" : "Next round →";
535
  $("#next-btn").onclick = rev.game_over ? showFinal : nextRound;
536
  }
537
 
 
546
  <td><div class="who">${avatarOf(key)}<div>${esc(labelOf(key))}
547
  <div class="sub">${fmtKm(p.distance_km)} · ${sub}</div></div></div></td>
548
  <td class="pts">${fmtPts(p.score)}<div class="sub">Σ ${fmtPts(rev.totals[key])}</div></td>
549
+ <td class="chev">${key === HUMAN ? "" : "<span>Trace ›</span>"}</td></tr>`;
550
  }).join("");
551
  $("#round-table").innerHTML = `<table class="lb"><thead><tr><th>#</th><th>Player</th>
552
  <th style="text-align:right">Points</th><th></th></tr></thead><tbody>${rows}</tbody></table>
553
+ <p class="note">${svgIcon("i-info")}<span>Click an AI to replay everything it saw, thought and did.</span></p>`;
554
  $("#round-table").querySelectorAll("tr.clickable").forEach((tr) =>
555
  tr.addEventListener("click", () => openReplay(tr.dataset.key)));
556
  }
 
597
  const p = rev.participants[key];
598
  if (!p?.trace) return;
599
  const color = colorOf(key);
600
+ const img = (ref) => traceImg(key, state.round.round, ref);
 
601
 
602
  let html = `<div class="verdict" style="--c:${color}">
603
  ${p.guess ? `Guessed <b>${esc(p.guess.country || `${p.guess.lat.toFixed(3)}, ${p.guess.lng.toFixed(3)}`)}</b>:
 
609
 
610
  for (const e of p.trace) {
611
  if (e.role === "user") {
612
+ html += `<div class="step"><div class="tl">Game</div><div class="text">${esc(e.text)}</div>${img(e.image_ref)}</div>`;
613
  } else if (e.role === "assistant") {
614
  const calls = callChips(e.tool_calls);
615
+ html += `<div class="step" style="--c:${color}"><div class="tl">${esc(labelOf(key))}</div>
616
  ${e.text ? `<div class="text">${esc(e.text)}</div>` : ""}
617
  ${e.reasoning ? `<details><summary>thinking</summary><div class="thinking">${esc(e.reasoning)}</div></details>` : ""}
618
  ${calls}</div>`;
619
  } else if (e.role === "tool") {
620
+ html += `<div class="step"><div class="tl">${ACTION_ICONS[e.name] || ""} ${esc(e.name)} result</div>
621
  <div class="text muted">${esc(e.text)}</div>${img(e.image_ref)}</div>`;
622
  }
623
  }
624
  $("#replay-title").innerHTML = `${avatarOf(key, "sm")} ${esc(labelOf(key))}`;
625
  $("#replay-body").innerHTML = html;
626
+ $("#replay-body").querySelectorAll("img").forEach((im) => im.addEventListener("click", () => openLightbox(im.src)));
 
 
627
  $("#round-table").classList.add("hidden");
628
  $("#replay").classList.remove("hidden");
629
  $("#replay").scrollTop = 0;
 
638
  const myRank = order.indexOf(HUMAN) + 1;
639
  const beaten = order.length - myRank;
640
  const machines = order.length - 1;
641
+ $("#final-title").textContent = myRank === 1 ? "Humanity prevails" : myRank === order.length ? "The machines win" : `You finished #${myRank}`;
642
  $("#final-sub").textContent = `You beat ${beaten} of ${machines} AI opponent${machines > 1 ? "s" : ""} with ${fmtPts(totals[HUMAN])} points.`;
643
 
644
  const podiumOrder = [1, 0, 2].filter((i) => i < order.length);
static/index.html CHANGED
@@ -3,46 +3,75 @@
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>GeoGuessr Arena — you vs the machines</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
- <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,700;12..96,800&family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
9
  <link rel="stylesheet" href="/static/style.css">
10
  </head>
11
  <body>
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  <!-- ============ LOBBY ============ -->
14
  <section id="lobby" class="screen active">
15
  <div class="lobby-wrap">
16
- <header class="marquee">
17
- <span class="eyebrow">🌍 GeoGuessr Arena</span>
18
- <h1><span class="you">You</span><span class="vs">vs</span><span class="them">the machines</span></h1>
19
  <p>Same drop, same clock. You roam Street View while AI agents walk, look, zoom and pin with tools.
20
- Their feed stays blind until the reveal: you see <em>what</em> they do, never what they see.</p>
21
- </header>
22
-
23
- <div class="versus">
24
- <div class="corner player-corner">
25
- <div class="corner-label">Player 1</div>
26
- <span id="player-avatar" class="avatar xl human" style="--c:var(--human)">?</span>
27
- <input id="player-name" class="name-input" maxlength="40" placeholder="Your name" autocomplete="nickname">
28
- <div class="field-label">Map</div>
29
- <div id="mode-picker" class="segmented"></div>
30
- <div class="rules" id="rules-hint"></div>
31
- <div id="hf-auth" class="hf-auth"></div>
32
- </div>
33
-
34
- <div class="vs-badge">VS</div>
35
 
36
- <div class="corner machine-corner">
37
- <div class="corner-label"><span>The machines</span><span class="count" id="opp-count"></span></div>
38
- <div id="model-grid" class="roster"></div>
39
- <p class="hint" id="key-hint"></p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  </div>
41
- </div>
42
 
43
- <div class="lobby-actions">
44
- <button id="start-btn" class="btn primary big">▶ Start match</button>
45
- <span id="lobby-error" class="error"></span>
 
 
 
 
 
46
  </div>
47
  </div>
48
  </section>
@@ -51,26 +80,26 @@
51
  <section id="play" class="screen">
52
  <div id="pano"></div>
53
 
 
 
54
  <div class="scoreboard">
55
- <div class="cell"><small>Round</small><b id="hud-round">1/5</b></div>
56
  <div class="timer" id="hud-timer">3:00</div>
57
- <div class="cell"><small>Score</small><b id="hud-score">0</b></div>
58
  </div>
59
 
60
- <button id="return-btn" class="btn ghost return" title="Back to where you started">⟲ Back to start</button>
61
-
62
  <aside id="rivals" class="rivals">
63
- <div class="rivals-head"><span>Rivals</span><span class="live">LIVE · BLIND</span></div>
64
  <div id="rival-list"></div>
65
  </aside>
66
 
67
  <div id="guess-box" class="guess-box">
68
  <div id="guess-map"></div>
69
- <button id="guess-btn" class="btn primary" disabled>Place your pin</button>
70
  </div>
71
 
72
  <div id="locked-banner" class="locked-banner hidden">
73
- 🔒 Locked in. <span id="waiting-text">Waiting for the machines…</span>
74
  </div>
75
  </section>
76
 
@@ -86,13 +115,13 @@
86
  <div id="round-table"></div>
87
  <div id="replay" class="replay hidden">
88
  <div class="replay-head">
89
- <button id="replay-back" class="btn ghost small">← Results</button>
90
  <b id="replay-title"></b>
91
  </div>
92
  <div id="replay-body"></div>
93
  </div>
94
  <div class="reveal-actions">
95
- <button id="next-btn" class="btn primary big">Next round →</button>
96
  </div>
97
  </aside>
98
  </section>
@@ -105,10 +134,16 @@
105
  <p id="final-sub" class="muted"></p>
106
  <div id="podium" class="podium"></div>
107
  <div id="final-table"></div>
108
- <button id="again-btn" class="btn primary big">↻ Rematch</button>
109
  </div>
110
  </section>
111
 
 
 
 
 
 
 
112
  <div id="loading" class="loading hidden"><div class="spinner"></div><span id="loading-text">Finding a place…</span></div>
113
  <div id="lightbox" class="lightbox hidden"><img alt=""></div>
114
 
 
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>GeoGuess — you vs machines</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
9
  <link rel="stylesheet" href="/static/style.css">
10
  </head>
11
  <body>
12
 
13
+ <svg width="0" height="0" style="position:absolute" aria-hidden="true">
14
+ <symbol id="i-globe" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.6 2.6 3.8 5.6 3.8 9s-1.2 6.4-3.8 9c-2.6-2.6-3.8-5.6-3.8-9S9.4 5.6 12 3z"/></g></symbol>
15
+ <symbol id="i-pad" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M7 7h10a5 5 0 0 1 4.9 6l-.8 3.6a2.5 2.5 0 0 1-4.3 1.1L14.5 15h-5l-2.3 2.7a2.5 2.5 0 0 1-4.3-1.1L2.1 13A5 5 0 0 1 7 7z"/><path d="M7.5 10v3M6 11.5h3"/><circle cx="16" cy="10.5" r=".6" fill="currentColor"/><circle cx="17.5" cy="12.5" r=".6" fill="currentColor"/></g></symbol>
16
+ <symbol id="i-bot" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><rect x="4" y="7" width="16" height="12" rx="3"/><path d="M12 3v4M9.5 12v1.5M14.5 12v1.5M1.5 12v3M22.5 12v3"/></g></symbol>
17
+ <symbol id="i-lock" viewBox="0 0 24 24"><path fill="currentColor" d="M7 10V8a5 5 0 0 1 10 0v2h1a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h1zm2 0h6V8a3 3 0 0 0-6 0v2z"/></symbol>
18
+ <symbol id="i-check" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="M6 12.5l4 4 8-9"/></symbol>
19
+ <symbol id="i-info" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 7.5v.5" stroke-linecap="round"/></g></symbol>
20
+ <symbol id="i-pen" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" d="M4 20l1-4L16 5l3 3L8 19l-4 1zM14 7l3 3"/></symbol>
21
+ <symbol id="i-back" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M20 12H5M11 6l-6 6 6 6"/></symbol>
22
+ </svg>
23
+
24
+ <header class="topbar">
25
+ <a class="brand" href="/"><span class="logo"><svg><use href="#i-globe"/></svg></span><span>GeoGuess</span></a>
26
+ <nav>
27
+ <button id="nav-rules" class="link">How it works</button>
28
+ <a id="nav-traces" class="link" target="_blank" rel="noopener">Traces</a>
29
+ <span id="nav-auth"></span>
30
+ </nav>
31
+ </header>
32
+
33
  <!-- ============ LOBBY ============ -->
34
  <section id="lobby" class="screen active">
35
  <div class="lobby-wrap">
36
+ <div class="hero">
37
+ <h1>You <span class="vs">vs</span> Machines</h1>
 
38
  <p>Same drop, same clock. You roam Street View while AI agents walk, look, zoom and pin with tools.
39
+ Their feed stays blind until you lock in: you see what they do, never what they see.</p>
40
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
+ <div class="lobby-grid">
43
+ <div class="game-col">
44
+ <section class="panel">
45
+ <header class="panel-head"><span class="ico"><svg><use href="#i-pad"/></svg></span><h2>Your Game</h2></header>
46
+ <div class="panel-body">
47
+ <div class="player-row">
48
+ <span id="player-avatar" class="avatar md human"></span>
49
+ <input id="player-name" maxlength="40" placeholder="Your name" autocomplete="nickname">
50
+ <button id="name-change" class="link muted">Change <svg class="i"><use href="#i-pen"/></svg></button>
51
+ <span id="sign-out-slot"></span>
52
+ </div>
53
+ <h3 class="label">Map</h3>
54
+ <div id="mode-picker" class="segmented"></div>
55
+ <div id="rules-hint" class="stats"></div>
56
+ <p id="login-note" class="note hidden"><svg class="i"><use href="#i-info"/></svg>
57
+ <span>Sign in to unlock the open models and the rest of the roster. Inference runs on your HF account.</span></p>
58
+ </div>
59
+ </section>
60
+ <div class="cta">
61
+ <a id="hf-login" class="btn dark hidden" href="/oauth/huggingface/login">Sign in with Hugging Face</a>
62
+ <button id="start-btn" class="btn primary">Start Match</button>
63
+ <span id="lobby-error" class="error"></span>
64
+ </div>
65
  </div>
 
66
 
67
+ <section class="panel opponents">
68
+ <header class="panel-head"><span class="ico"><svg><use href="#i-bot"/></svg></span><h2>Select Opponents</h2>
69
+ <span class="count" id="opp-count"></span></header>
70
+ <div class="panel-body">
71
+ <div id="model-grid" class="roster"></div>
72
+ <p class="note"><svg class="i"><use href="#i-info"/></svg><span id="key-hint"></span></p>
73
+ </div>
74
+ </section>
75
  </div>
76
  </div>
77
  </section>
 
80
  <section id="play" class="screen">
81
  <div id="pano"></div>
82
 
83
+ <button id="return-btn" class="btn dark return" title="Back to where you started"><svg class="i"><use href="#i-back"/></svg> Back to start</button>
84
+
85
  <div class="scoreboard">
86
+ <div class="cell round"><b id="hud-round">1/3</b><small>Rounds</small></div>
87
  <div class="timer" id="hud-timer">3:00</div>
88
+ <div class="cell score"><b id="hud-score">0</b><small>Score</small></div>
89
  </div>
90
 
 
 
91
  <aside id="rivals" class="rivals">
92
+ <div class="rivals-head"><span>Rivals</span><span class="live"><i></i>Live <i></i><span id="live-mode">Blind</span></span></div>
93
  <div id="rival-list"></div>
94
  </aside>
95
 
96
  <div id="guess-box" class="guess-box">
97
  <div id="guess-map"></div>
98
+ <button id="guess-btn" class="btn dark" disabled>Place your Pin</button>
99
  </div>
100
 
101
  <div id="locked-banner" class="locked-banner hidden">
102
+ <b>Locked in.</b> <span id="waiting-text">Waiting for the machines…</span>
103
  </div>
104
  </section>
105
 
 
115
  <div id="round-table"></div>
116
  <div id="replay" class="replay hidden">
117
  <div class="replay-head">
118
+ <button id="replay-back" class="btn dark small"><svg class="i"><use href="#i-back"/></svg> Results</button>
119
  <b id="replay-title"></b>
120
  </div>
121
  <div id="replay-body"></div>
122
  </div>
123
  <div class="reveal-actions">
124
+ <button id="next-btn" class="btn primary">Next round →</button>
125
  </div>
126
  </aside>
127
  </section>
 
134
  <p id="final-sub" class="muted"></p>
135
  <div id="podium" class="podium"></div>
136
  <div id="final-table"></div>
137
+ <button id="again-btn" class="btn primary">Rematch</button>
138
  </div>
139
  </section>
140
 
141
+ <div id="rules-modal" class="modal hidden">
142
+ <div class="modal-card">
143
+ <header class="panel-head"><span class="ico"><svg><use href="#i-info"/></svg></span><h2>How it works</h2></header>
144
+ <div class="panel-body" id="rules-body"></div>
145
+ </div>
146
+ </div>
147
  <div id="loading" class="loading hidden"><div class="spinner"></div><span id="loading-text">Finding a place…</span></div>
148
  <div id="lightbox" class="lightbox hidden"><img alt=""></div>
149
 
static/style.css CHANGED
@@ -1,305 +1,307 @@
 
1
  :root {
2
- --bg: #0b0f1a;
3
- --panel: rgba(14, 19, 33, 0.86);
4
- --panel-solid: #121828;
5
- --panel-2: #1a2440;
6
- --line: rgba(255, 255, 255, 0.09);
7
- --text: #eef1f8;
8
- --muted: #8b93a7;
9
- --accent: #ffc53d;
10
- --accent-deep: #b8860b;
11
- --accent-ink: #1a1300;
12
- --hot: #6b8cff;
13
- --good: #3ddc97;
14
- --bad: #ff6b6b;
15
- --human: #ffc53d;
16
- --radius: 18px;
17
- --shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
18
- --display: "Bricolage Grotesque", Inter, system-ui, sans-serif;
19
  --mono: "JetBrains Mono", ui-monospace, monospace;
20
  }
21
-
22
  * { box-sizing: border-box; }
23
- html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
24
- font-family: Inter, system-ui, -apple-system, sans-serif; -webkit-font-smoothing: antialiased; }
25
- button, input { font: inherit; }
26
- .muted { color: var(--muted); }
27
- .small { font-size: 12px; }
28
  .hidden { display: none !important; }
29
- .error { color: var(--bad); font-size: 14px; font-weight: 600; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
- /* ---------- screens ---------- */
32
- .screen { display: none; position: fixed; inset: 0; }
33
  .screen.active { display: block; }
34
- #lobby, #final { overflow-y: auto;
35
- background: radial-gradient(1200px 600px at 20% -10%, #1d2a52 0%, transparent 60%),
36
- radial-gradient(900px 500px at 110% 110%, #3a2a0a 0%, transparent 55%),
37
- var(--bg); }
38
 
39
  /* ---------- buttons ---------- */
40
- .btn { border: 1px solid var(--line); background: rgba(255,255,255,.07); color: var(--text);
41
- padding: 10px 16px; border-radius: 12px; cursor: pointer; font-weight: 700; transition: transform .08s, filter .15s, background .15s; }
42
- .btn:hover:not(:disabled) { background: rgba(255,255,255,.13); }
43
- .btn:disabled { opacity: .45; cursor: not-allowed; }
44
- .btn.primary { background: var(--accent); color: var(--accent-ink); border: 0;
45
- box-shadow: 0 5px 0 var(--accent-deep), 0 10px 24px rgba(255, 197, 61, .25);
46
- font-family: var(--display); text-transform: uppercase; letter-spacing: .6px; }
47
- .btn.primary:hover:not(:disabled) { filter: brightness(1.06); background: var(--accent); }
48
- .btn.primary:active:not(:disabled) { transform: translateY(4px); box-shadow: 0 1px 0 var(--accent-deep); }
49
- .btn.primary:disabled { box-shadow: 0 5px 0 var(--accent-deep); }
50
- .btn.big { padding: 16px 38px; font-size: 20px; border-radius: 16px; }
51
- .btn.small { padding: 6px 10px; font-size: 13px; }
52
- .btn.ghost { background: var(--panel); backdrop-filter: blur(8px); }
53
 
54
  /* ---------- avatars ---------- */
55
- .avatar { --s: 30px; width: var(--s); height: var(--s); flex: none; border-radius: 50%;
56
- display: inline-grid; place-items: center; background: var(--c, #666); color: #fff;
57
- font-family: var(--display); font-weight: 800; font-size: calc(var(--s) * .46); line-height: 1;
58
- box-shadow: 0 0 0 2px rgba(255,255,255,.9), 0 4px 12px rgba(0,0,0,.35); text-shadow: 0 1px 2px rgba(0,0,0,.35); }
59
- .avatar.human { color: var(--accent-ink); text-shadow: none; }
60
- .avatar.pic { background: #fff; overflow: hidden; }
61
  .avatar.pic img { width: 100%; height: 100%; object-fit: cover; display: block; }
62
- .avatar.sm { --s: 22px; box-shadow: 0 0 0 1.5px rgba(255,255,255,.85); }
63
- .avatar.lg { --s: 56px; }
64
- .avatar.xl { --s: 96px; box-shadow: 0 0 0 4px #fff, 0 0 0 10px rgba(255, 197, 61, .25), 0 12px 40px rgba(255, 197, 61, .35); }
 
 
65
 
66
  /* ---------- lobby ---------- */
67
- .lobby-wrap { max-width: 1180px; margin: 0 auto; padding: 44px 24px 64px; }
68
- .marquee { text-align: center; margin-bottom: 34px; }
69
- .eyebrow { display: inline-block; font-family: var(--display); font-weight: 800; text-transform: uppercase;
70
- letter-spacing: 3px; font-size: 13px; padding: 7px 16px; border-radius: 999px;
71
- background: rgba(255,255,255,.07); border: 1px solid var(--line); }
72
- .marquee h1 { font-family: var(--display); font-weight: 800; font-size: clamp(44px, 7.5vw, 92px);
73
- line-height: .95; margin: 20px 0 16px; letter-spacing: -2px; text-transform: uppercase; }
74
- .marquee h1 .you { color: var(--accent); text-shadow: 0 0 40px rgba(255, 197, 61, .35); }
75
- .marquee h1 .vs { display: inline-block; font-size: .45em; vertical-align: middle; margin: 0 .2em;
76
- padding: .12em .4em; border-radius: 12px; background: #fff; color: var(--bg); transform: rotate(-6deg) translateY(-.15em); }
77
- .marquee h1 .them { color: var(--text); }
78
- .marquee p { max-width: 640px; margin: 0 auto; color: var(--muted); line-height: 1.6; font-size: 17px; }
79
 
80
- .versus { display: grid; grid-template-columns: 320px 64px 1fr; align-items: stretch; }
81
- @media (max-width: 900px) {
82
- .versus { grid-template-columns: 1fr; gap: 0; }
83
- .versus .vs-badge { margin: -14px auto; }
84
- }
85
- .corner { background: var(--panel); border: 1px solid var(--line); border-radius: 24px; padding: 24px;
86
- box-shadow: var(--shadow); position: relative; }
87
- .player-corner { border-top: 4px solid var(--accent); text-align: center; display: flex; flex-direction: column; align-items: center; gap: 14px; }
88
- .machine-corner { border-top: 4px solid var(--hot); }
89
- .corner-label { font-family: var(--display); font-weight: 800; text-transform: uppercase; letter-spacing: 2px;
90
- font-size: 13px; color: var(--muted); display: flex; justify-content: space-between; align-items: center; width: 100%; }
91
- .player-corner .corner-label { justify-content: center; }
92
- .count { color: var(--hot); }
93
- .vs-badge { align-self: center; justify-self: center; z-index: 2; width: 72px; height: 72px; border-radius: 50%;
94
- display: grid; place-items: center; font-family: var(--display); font-weight: 800; font-size: 26px;
95
- background: var(--accent); color: var(--accent-ink);
96
- box-shadow: 0 0 0 6px var(--bg), 0 10px 30px rgba(255, 197, 61, .35); transform: rotate(-8deg); }
97
 
98
- .name-input { width: 100%; text-align: center; padding: 12px 14px; border-radius: 14px; border: 2px solid var(--line);
99
- background: rgba(0,0,0,.3); color: var(--text); font-family: var(--display); font-weight: 700; font-size: 20px; }
100
- .name-input:focus { outline: none; border-color: var(--accent); }
101
- .field-label { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: var(--muted); margin-bottom: -6px; }
102
- .segmented { display: flex; gap: 4px; padding: 4px; border-radius: 14px; background: rgba(0,0,0,.3);
103
- border: 1px solid var(--line); width: 100%; }
104
- .segmented button { flex: 1; padding: 9px; border-radius: 10px; border: 0; background: transparent;
105
- color: var(--muted); cursor: pointer; text-transform: capitalize; font-weight: 700; }
106
- .segmented button:hover { color: var(--text); }
107
- .segmented button.on { background: var(--accent); color: var(--accent-ink); }
108
- .rules { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 4px; }
109
- .chip { font-size: 12px; font-weight: 700; padding: 5px 10px; border-radius: 999px; background: rgba(255,255,255,.07);
110
- border: 1px solid var(--line); color: var(--muted); }
111
- .chip b { color: var(--text); }
112
- .hint { font-size: 13px; color: var(--muted); line-height: 1.5; margin: 14px 0 0; }
113
 
114
- .hf-auth { margin-top: auto; padding-top: 16px; border-top: 1px solid var(--line); width: 100%;
115
- display: flex; flex-direction: column; align-items: center; gap: 8px; }
116
- .hf-auth .hint { margin: 0; font-size: 12px; }
117
- .btn.hf { display: block; width: 100%; text-decoration: none; background: #fff; color: #111; border: 0; }
118
- .btn.hf:hover { background: #f1f1f1; }
119
- .btn.hf.nudge { animation: nudge .5s 2; box-shadow: 0 0 0 4px rgba(255, 197, 61, .6); }
120
- @keyframes nudge { 25% { transform: translateX(-5px); } 75% { transform: translateX(5px); } }
121
- .hf-user { display: flex; align-items: center; gap: 9px; font-size: 14px; }
122
- .hf-user img { width: 28px; height: 28px; border-radius: 50%; box-shadow: 0 0 0 2px #fff; }
123
- .hf-out { font-size: 12px; color: var(--muted); }
124
- .hf-out:hover { color: var(--text); }
125
 
126
- .roster { display: grid; grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 10px; margin-top: 16px; }
127
- .fighter { position: relative; border: 2px solid var(--line); border-radius: 18px; padding: 14px 10px 12px;
128
- background: rgba(0,0,0,.22); cursor: pointer; text-align: center; transition: transform .12s, border-color .12s, background .12s;
129
- display: flex; flex-direction: column; align-items: center; gap: 8px; }
130
- .fighter:hover:not(.disabled) { transform: translateY(-3px); border-color: color-mix(in srgb, var(--c) 60%, transparent); }
131
- .fighter .avatar { filter: grayscale(.85) brightness(.75); transition: filter .12s; }
132
- .fighter.on { border-color: var(--c); background: color-mix(in srgb, var(--c) 18%, rgba(0,0,0,.2));
133
- box-shadow: 0 0 0 4px color-mix(in srgb, var(--c) 18%, transparent), 0 10px 26px color-mix(in srgb, var(--c) 25%, transparent); }
134
- .fighter.on .avatar { filter: none; }
135
- .fighter.disabled { opacity: .35; cursor: not-allowed; }
136
- .fighter.locked { opacity: .6; }
137
- .fighter.locked:hover { transform: none; border-color: var(--line); }
138
- .fighter.hf .ftag { color: var(--accent); }
139
- .fighter .fname { font-family: var(--display); font-weight: 800; font-size: 15px; line-height: 1.15; }
140
- .fighter .ftag { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); }
141
- .fighter.byok .ftag { color: var(--accent); }
142
- .fighter .check { position: absolute; top: 9px; right: 9px; width: 22px; height: 22px; border-radius: 50%;
143
- border: 2px solid var(--line); display: grid; place-items: center; font-size: 12px; font-weight: 800; color: transparent; }
144
- .fighter.on .check { background: var(--c); border-color: var(--c); color: #fff; }
145
- .fighter input { width: 100%; padding: 7px 9px; border-radius: 9px; border: 1px solid var(--line); text-align: center;
146
- background: rgba(0,0,0,.4); color: var(--text); font-size: 12px; font-family: var(--mono); }
147
- .fighter input:focus { outline: none; border-color: var(--c); }
148
- .fighter select { width: 100%; margin-top: 6px; padding: 6px 8px; border-radius: 9px; border: 1px solid var(--line);
149
- background: rgba(0,0,0,.4); color: var(--text); font-size: 11.5px; font-family: var(--mono); text-overflow: ellipsis; }
150
- .fighter select:focus { outline: none; border-color: var(--c); }
151
 
152
- .lobby-actions { display: flex; flex-direction: column; align-items: center; gap: 12px; margin-top: 34px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
  /* ---------- play ---------- */
155
- #pano { position: absolute; inset: 0; background: #000; }
156
- .scoreboard { position: absolute; top: 14px; left: 50%; transform: translateX(-50%); z-index: 5; display: flex;
157
- align-items: stretch; background: var(--panel); backdrop-filter: blur(12px); border: 1px solid var(--line);
158
- border-radius: 18px; box-shadow: var(--shadow); overflow: hidden; }
159
- .scoreboard .cell { padding: 8px 20px; display: flex; flex-direction: column; align-items: center; justify-content: center; }
160
- .scoreboard .cell small { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: var(--muted); }
161
- .scoreboard .cell b { font-family: var(--display); font-size: 20px; font-weight: 800; font-variant-numeric: tabular-nums; }
162
- .scoreboard .timer { background: rgba(0,0,0,.35); font-family: var(--mono); font-weight: 700; font-size: 28px;
163
- color: var(--accent); min-width: 128px; text-align: center; border-left: 1px solid var(--line); border-right: 1px solid var(--line);
164
- display: grid; place-items: center; padding: 0 20px; }
 
165
  .scoreboard .timer.warn { color: var(--bad); animation: pulse 1s infinite; }
166
- @keyframes pulse { 50% { opacity: .5; } }
167
- .return { position: absolute; top: 14px; left: 14px; z-index: 5; }
168
 
169
- .rivals { position: absolute; top: 14px; right: 14px; width: 290px; z-index: 5; background: var(--panel);
170
- backdrop-filter: blur(12px); border: 1px solid var(--line); border-top: 3px solid var(--hot); border-radius: var(--radius);
171
- box-shadow: var(--shadow); padding: 12px 14px; max-height: calc(100vh - 320px); overflow-y: auto; }
172
- .rivals-head { display: flex; justify-content: space-between; align-items: center; font-family: var(--display); font-weight: 800;
173
- text-transform: uppercase; font-size: 13px; letter-spacing: 1.5px; margin-bottom: 6px; }
174
- .live { font-family: Inter, sans-serif; font-size: 10px; letter-spacing: 1px; color: var(--hot); display: flex; align-items: center; gap: 5px; }
175
- .live::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--hot); animation: pulse 1.2s infinite; }
176
- .rival { padding: 10px 0; border-top: 1px solid var(--line); }
177
- .rival:first-of-type { border-top: 0; }
178
- .rival .row { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 14px; }
179
- .rival .status { margin-left: auto; font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .8px;
180
- padding: 3px 8px; border-radius: 999px; background: rgba(255,255,255,.08); color: var(--muted); }
181
- .rival .status.thinking::after { content: "…"; animation: pulse 1s infinite; }
182
- .rival .status.locked { background: color-mix(in srgb, var(--good) 22%, transparent); color: var(--good); }
183
- .rival .status.out, .rival .status.error { background: color-mix(in srgb, var(--bad) 20%, transparent); color: var(--bad); }
184
- .bar { height: 5px; border-radius: 5px; background: rgba(255,255,255,.08); margin: 8px 0 5px; overflow: hidden; }
185
- .bar > i { display: block; height: 100%; background: var(--c); width: 0; transition: width .4s; border-radius: 5px; }
186
- .rival .last { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
187
- .rival .last.flash { animation: flash .8s; }
188
  @keyframes flash { from { color: var(--text); } }
189
- /* After the human locks in: the panel widens and shows each agent's reasoning. */
 
190
  .rival .feed { display: none; }
191
- .rivals.unlocked { width: 400px; max-height: calc(100vh - 100px); border-top-color: var(--accent); }
192
- .rivals.unlocked .live { color: var(--accent); }
193
- .rivals.unlocked .live::before { background: var(--accent); }
194
- .rivals.unlocked .feed { display: block; max-height: 260px; overflow-y: auto; margin-top: 6px; padding-left: 10px;
195
  border-left: 2px solid var(--c); }
196
- .thought { padding: 6px 0; font-size: 12.5px; line-height: 1.45; border-top: 1px dashed var(--line); }
197
  .thought:first-child { border-top: 0; }
 
198
  .thought .text { white-space: pre-wrap; }
199
- .thought details summary { cursor: pointer; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .8px; }
 
200
  .thought .thinking { white-space: pre-wrap; color: var(--muted); font-size: 12px; margin: 4px 0; }
201
- .thought .call { display: inline-block; margin: 4px 4px 0 0; }
202
 
203
- .guess-box { position: absolute; right: 14px; bottom: 14px; z-index: 6; width: 320px; transition: width .25s, opacity .25s;
204
- opacity: .85; display: flex; flex-direction: column; gap: 10px; }
205
- .guess-box:hover, .guess-box.pinned { width: min(640px, 60vw); opacity: 1; }
206
- #guess-map { height: 210px; border-radius: var(--radius); overflow: hidden; border: 3px solid rgba(255,255,255,.9);
207
- box-shadow: var(--shadow); transition: height .25s; }
208
- .guess-box:hover #guess-map, .guess-box.pinned #guess-map { height: min(440px, 52vh); }
209
- #guess-btn { width: 100%; padding: 14px; font-size: 18px; }
210
 
211
- .locked-banner { position: absolute; left: 50%; bottom: 28px; transform: translateX(-50%); z-index: 7;
212
- background: var(--panel); backdrop-filter: blur(10px); border: 2px solid var(--good); color: var(--text);
213
- padding: 13px 24px; border-radius: 999px; box-shadow: var(--shadow); font-weight: 700; white-space: nowrap; }
214
-
215
- @media (max-width: 760px) {
216
- .scoreboard { top: 10px; }
217
- .scoreboard .cell { padding: 6px 12px; }
218
- .scoreboard .timer { min-width: 96px; font-size: 22px; padding: 0 12px; }
219
- .return { top: auto; bottom: 14px; left: 14px; }
220
- .rivals { top: 76px; left: 10px; right: auto; width: 200px; max-height: 34vh; padding: 8px 10px; }
221
- .rival .last { display: none; }
222
- .rivals.unlocked { width: calc(100vw - 20px); max-height: 60vh; }
223
- .guess-box, .guess-box:hover, .guess-box.pinned { width: calc(100vw - 20px); right: 10px; bottom: 10px; }
224
- .guess-box:not(.pinned) #guess-map { height: 150px; }
225
- }
226
 
227
  /* ---------- reveal ---------- */
228
- #reveal-map { position: absolute; left: 0; top: 0; bottom: 0; right: 460px; }
229
- .reveal-panel { position: absolute; right: 0; top: 0; bottom: 0; width: 460px; background: var(--panel-solid);
230
- border-left: 1px solid var(--line); display: flex; flex-direction: column; }
231
- @media (max-width: 900px) {
232
- #reveal-map { right: 0; bottom: 55vh; }
233
- .reveal-panel { width: 100%; top: 45vh; border-left: 0; border-top: 1px solid var(--line); }
234
- }
235
- .reveal-head { padding: 22px 22px 14px; background: linear-gradient(180deg, var(--panel-2), transparent); }
236
- .reveal-head .eyebrow { font-size: 11px; padding: 5px 12px; }
237
- .reveal-head h2 { font-family: var(--display); font-weight: 800; margin: 12px 0 6px; font-size: 30px; line-height: 1.1; letter-spacing: -.5px; }
238
  .reveal-head h2 em { font-style: normal; color: var(--accent); }
239
- #round-table, #replay { flex: 1; overflow-y: auto; padding: 4px 14px 10px; }
240
- .reveal-actions { padding: 16px 22px 20px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; }
241
  .reveal-actions .btn { width: 100%; }
242
 
243
- .lb { width: 100%; border-collapse: separate; border-spacing: 0 6px; font-size: 14px; }
244
- .lb th { text-align: left; font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 1.2px;
245
- font-weight: 700; padding: 4px 10px; }
246
- .lb td { padding: 10px; background: rgba(255,255,255,.04); }
247
- .lb td:first-child { border-radius: 12px 0 0 12px; }
248
- .lb td:last-child { border-radius: 0 12px 12px 0; }
249
  .lb tr.clickable { cursor: pointer; }
250
- .lb tr.clickable:hover td { background: rgba(255,255,255,.08); }
251
- .lb tr.me td { background: color-mix(in srgb, var(--human) 14%, transparent); }
252
- .lb tr.me td:first-child { box-shadow: inset 3px 0 0 var(--human); }
253
- .lb .rank { font-family: var(--display); font-weight: 800; font-size: 18px; color: var(--muted); width: 36px; text-align: center; }
254
- .lb tr:nth-child(1) .rank { color: var(--accent); }
255
- .lb .pts { font-family: var(--display); font-weight: 800; font-size: 18px; text-align: right; font-variant-numeric: tabular-nums; }
256
- .lb .who { display: flex; align-items: center; gap: 9px; font-weight: 700; }
257
- .lb .sub { font-size: 12px; color: var(--muted); font-weight: 500; font-family: Inter, sans-serif; }
258
- .lb .chev { color: var(--muted); text-align: right; width: 22px; font-size: 18px; }
 
 
259
 
260
- .replay-head { display: flex; align-items: center; gap: 10px; padding: 8px 4px 12px; position: sticky; top: -4px;
261
- background: var(--panel-solid); z-index: 1; }
262
- .replay-head b { display: flex; align-items: center; gap: 8px; }
263
- .step { border-left: 2px solid var(--line); margin: 0 0 0 8px; padding: 2px 0 16px 18px; position: relative; }
264
- .step::before { content: ""; position: absolute; left: -6px; top: 5px; width: 10px; height: 10px; border-radius: 50%;
265
- background: var(--c, var(--muted)); box-shadow: 0 0 0 3px var(--panel-solid); }
266
- .step .label { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 1.2px; color: var(--muted); margin-bottom: 4px; }
267
- .step .text { white-space: pre-wrap; font-size: 13px; line-height: 1.55; }
268
  .step details { margin-top: 6px; }
269
  .step summary { cursor: pointer; font-size: 12px; color: var(--muted); }
270
- .step .thinking { white-space: pre-wrap; font-size: 12px; color: var(--muted); line-height: 1.5; margin-top: 4px; }
271
- .call { display: inline-block; font-family: var(--mono); font-size: 12px; background: rgba(255,255,255,.07);
272
- border: 1px solid var(--line); border-radius: 8px; padding: 3px 8px; margin: 5px 4px 0 0; }
273
- .step img { display: block; width: 100%; border-radius: 12px; margin-top: 8px; cursor: zoom-in; border: 1px solid var(--line); }
274
- .verdict { background: rgba(255,255,255,.05); border: 1px solid var(--line); border-left: 4px solid var(--c, var(--muted));
275
- border-radius: 14px; padding: 14px; margin: 4px 0 16px; font-size: 14px; }
276
 
277
  /* ---------- final ---------- */
278
- .final-wrap { max-width: 780px; margin: 0 auto; padding: 56px 24px; text-align: center; }
279
- .final-wrap h1 { font-family: var(--display); font-weight: 800; font-size: clamp(40px, 7vw, 68px); margin: 18px 0 10px;
280
- letter-spacing: -1.5px; text-transform: uppercase; line-height: 1; }
281
- .final-wrap > p { font-size: 17px; }
282
- .podium { display: flex; justify-content: center; align-items: flex-end; gap: 12px; margin: 44px 0 28px; }
283
- .podium .place { width: 180px; display: flex; flex-direction: column; align-items: center; gap: 10px; }
284
- .podium .nm { font-family: var(--display); font-weight: 800; font-size: 16px; }
285
- .podium .block { width: 100%; border-radius: 16px 16px 0 0; display: flex; flex-direction: column; align-items: center;
286
- justify-content: flex-start; padding-top: 14px; gap: 4px;
287
- background: linear-gradient(180deg, color-mix(in srgb, var(--c) 45%, var(--panel-2)), var(--panel-solid));
288
- border: 1px solid var(--line); border-bottom: 0; }
289
- .podium .block .n { font-family: var(--display); font-weight: 800; font-size: 44px; line-height: 1; }
290
- .podium .block .pts { font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; }
291
- .podium .p1 .block { height: 190px; }
292
  .podium .p2 .block { height: 140px; }
293
  .podium .p3 .block { height: 105px; }
 
 
294
  #final-table { text-align: left; margin-bottom: 32px; }
 
295
 
296
  /* ---------- overlays ---------- */
297
- .loading { position: fixed; inset: 0; background: rgba(5, 8, 15, .8); backdrop-filter: blur(6px); z-index: 50; display: flex;
298
- flex-direction: column; align-items: center; justify-content: center; gap: 18px;
299
- font-family: var(--display); font-weight: 800; font-size: 20px; text-transform: uppercase; letter-spacing: 1px; }
300
- .spinner { width: 54px; height: 54px; border-radius: 50%; border: 5px solid rgba(255,255,255,.12);
301
- border-top-color: var(--accent); border-right-color: var(--hot); animation: spin .8s linear infinite; }
302
  @keyframes spin { to { transform: rotate(360deg); } }
303
- .lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.88); z-index: 60; display: flex; align-items: center;
304
  justify-content: center; cursor: zoom-out; }
305
- .lightbox img { max-width: 92vw; max-height: 92vh; border-radius: 14px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* GeoGuess: black, square, one orange. */
2
  :root {
3
+ --bg: #0c0c0e; --bar: #111113; --panel: #151517; --cell: #19191c;
4
+ --line: #2a2a2e; --line-2: #3a3a3f; --text: #f4f4f5; --muted: #8a8a92; --dim: #5c5c63;
5
+ --accent: #ff5a14; --accent-2: #ff7a3d; --good: #2fbf71; --bad: #ff4d4d;
6
+ --human: #ff5a14; --topbar: 64px;
7
+ --sans: "Inter Tight", Inter, system-ui, -apple-system, sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
8
  --mono: "JetBrains Mono", ui-monospace, monospace;
9
  }
 
10
  * { box-sizing: border-box; }
11
+ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); font-family: var(--sans);
12
+ -webkit-font-smoothing: antialiased; }
13
+ button, input, select { font: inherit; color: inherit; }
14
+ a { color: inherit; }
 
15
  .hidden { display: none !important; }
16
+ .muted { color: var(--muted); }
17
+ .error { color: var(--bad); font-size: 14px; }
18
+ svg.i { width: 15px; height: 15px; vertical-align: -2px; flex: none; }
19
+
20
+ /* ---------- top bar ---------- */
21
+ .topbar { position: fixed; inset: 0 0 auto 0; height: var(--topbar); z-index: 40; display: flex; align-items: center;
22
+ justify-content: space-between; background: var(--bar); border-bottom: 1px solid #1e1e21; padding-right: 32px; }
23
+ .brand { display: flex; align-items: center; gap: 16px; text-decoration: none; font-size: 22px; font-weight: 500; }
24
+ .brand .logo { width: var(--topbar); height: var(--topbar); background: var(--accent); display: grid; place-items: center; }
25
+ .brand .logo svg { width: 34px; height: 34px; color: #2a0d00; }
26
+ .topbar nav { display: flex; align-items: center; gap: 26px; font-size: 18px; }
27
+ .link { background: none; border: 0; padding: 0; cursor: pointer; text-decoration: none; color: var(--text);
28
+ display: inline-flex; align-items: center; gap: 6px; }
29
+ .link:hover { color: var(--accent-2); }
30
+ .link.accent { color: var(--accent); }
31
+ .nav-user { display: inline-flex; align-items: center; gap: 8px; color: var(--accent); }
32
 
33
+ .screen { display: none; position: fixed; inset: var(--topbar) 0 0 0; overflow: hidden; }
 
34
  .screen.active { display: block; }
35
+ #lobby, #final { overflow-y: auto; }
 
 
 
36
 
37
  /* ---------- buttons ---------- */
38
+ .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 1px solid transparent;
39
+ border-radius: 0; padding: 16px 22px; font-size: 18px; font-weight: 500; cursor: pointer; text-decoration: none;
40
+ transition: background .12s, border-color .12s, opacity .12s; }
41
+ .btn:disabled { cursor: not-allowed; opacity: .55; }
42
+ .btn.primary { background: var(--accent); color: #fff; }
43
+ .btn.primary:not(:disabled):hover { background: var(--accent-2); }
44
+ .btn.dark { background: rgba(17,17,19,.92); color: var(--text); border-color: var(--line); }
45
+ .btn.dark:not(:disabled):hover { border-color: var(--line-2); background: #1a1a1d; }
46
+ .btn.small { padding: 8px 12px; font-size: 14px; }
47
+ .btn.nudge { animation: nudge .5s 2; border-color: var(--accent); }
48
+ @keyframes nudge { 25% { transform: translateX(-5px); } 75% { transform: translateX(5px); } }
 
 
49
 
50
  /* ---------- avatars ---------- */
51
+ .avatar { --s: 28px; width: var(--s); height: var(--s); flex: none; display: inline-grid; place-items: center;
52
+ background: var(--c, #444); color: #fff; font-weight: 600; font-size: calc(var(--s) * .5); line-height: 1;
53
+ border-radius: 4px; overflow: hidden; }
54
+ .avatar.pic { background: #fff; }
 
 
55
  .avatar.pic img { width: 100%; height: 100%; object-fit: cover; display: block; }
56
+ .avatar.human { background: var(--human); }
57
+ .avatar.sm { --s: 22px; }
58
+ .avatar.md { --s: 40px; }
59
+ .avatar.lg { --s: 48px; }
60
+ .avatar.xl { --s: 88px; }
61
 
62
  /* ---------- lobby ---------- */
63
+ .lobby-wrap { max-width: 1700px; margin: 0 auto; padding: 0 56px 64px; }
64
+ .hero { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 48px; padding: 22px 56px 28px;
65
+ margin: 0 -56px; border-bottom: 1px solid #1e1e21;
66
+ background-image: linear-gradient(90deg, #1b1b1e 1px, transparent 1px); background-size: 130px 100%; }
67
+ .hero h1 { margin: 0; font-size: clamp(54px, 6.4vw, 92px); font-weight: 500; letter-spacing: -.035em; line-height: 1; }
68
+ .hero h1 .vs { color: var(--accent); }
69
+ .hero p { margin: 0; max-width: 660px; font-size: 15px; line-height: 1.35; color: #d8d8dc; }
 
 
 
 
 
70
 
71
+ .lobby-grid { display: grid; grid-template-columns: minmax(380px, 540px) 1fr; gap: 64px; margin-top: 56px; align-items: start; }
72
+ .game-col { display: flex; flex-direction: column; gap: 12px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
+ .panel { background: var(--panel); border: 1px solid #202023; }
75
+ .panel-head { display: flex; align-items: center; height: 64px; border-bottom: 1px solid #202023; padding-right: 24px; }
76
+ .panel-head .ico { width: 64px; height: 64px; background: var(--accent); display: grid; place-items: center; flex: none;
77
+ margin: -1px 0 -1px -1px; }
78
+ .panel-head .ico svg { width: 32px; height: 32px; color: #1a0800; }
79
+ .panel-head h2 { margin: 0 0 0 30px; font-size: 22px; font-weight: 500; }
80
+ .panel-head .count { margin-left: auto; font-size: 18px; }
81
+ .panel-body { padding: 40px 40px 28px; }
 
 
 
 
 
 
 
82
 
83
+ .player-row { display: flex; align-items: center; gap: 14px; background: var(--cell); border: 1px solid #222226; padding: 18px 20px; }
84
+ .player-row input { flex: 1; min-width: 0; background: none; border: 0; font-size: 17px; outline: none; padding: 4px 0;
85
+ border-bottom: 1px solid transparent; }
86
+ .player-row input:focus { border-bottom-color: var(--accent); }
87
+ .player-row .link { font-size: 16px; }
88
+ .player-row .link.muted { color: var(--muted); }
89
+ .player-row .link.muted:hover { color: var(--text); }
 
 
 
 
90
 
91
+ .panel .label { font-size: 22px; font-weight: 500; margin: 40px 0 12px; }
92
+ .segmented { display: flex; gap: 8px; background: var(--cell); border: 1px solid #222226; padding: 12px; }
93
+ .segmented button { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 10px; padding: 16px;
94
+ border: 0; background: none; color: var(--muted); font-size: 21px; cursor: pointer; text-transform: capitalize; }
95
+ .segmented button svg { width: 30px; height: 30px; }
96
+ .segmented button.on { background: var(--accent); color: #fff; }
97
+ .segmented button:not(.on):hover { color: var(--text); }
98
+ .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 12px; }
99
+ .stat { background: var(--cell); border: 1px solid #222226; padding: 16px 6px 12px; text-align: center; }
100
+ .stat b { display: block; font-size: 38px; font-weight: 500; line-height: 1.05; letter-spacing: -.02em; }
101
+ .stat small { color: var(--muted); font-size: 15px; }
102
+ .note { display: flex; gap: 8px; align-items: flex-start; color: var(--muted); font-size: 14px; line-height: 1.25; margin: 28px 0 0; }
103
+ .note .i { margin-top: 1px; }
104
+ .cta { display: flex; flex-direction: column; gap: 12px; }
105
+ .cta .btn { width: 100%; }
 
 
 
 
 
 
 
 
 
 
106
 
107
+ .roster { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
108
+ .fighter { position: relative; background: #121214; border: 1px solid #222226; padding: 18px 20px 16px; cursor: pointer;
109
+ display: flex; flex-direction: column; align-items: flex-start; min-height: 168px; transition: border-color .12s, background .12s; }
110
+ .fighter:hover { border-color: color-mix(in srgb, var(--c) 60%, #222226); }
111
+ .fighter.on { border-color: var(--c); background: color-mix(in srgb, var(--c) 7%, #121214); }
112
+ .fighter .avatar { margin-bottom: 14px; }
113
+ .fighter .fname { font-size: 18px; font-weight: 500; padding-right: 24px; word-break: break-word; }
114
+ .fighter .org { color: var(--muted); font-size: 15px; margin-top: 2px; }
115
+ .fighter .tag { margin-top: 8px; border: 1px solid #2c2c31; background: #19191c; padding: 3px 7px; font-size: 15px; }
116
+ .fighter .mark { position: absolute; top: 18px; right: 18px; width: 22px; height: 22px; border-radius: 50%;
117
+ border: 2px solid var(--c); display: grid; place-items: center; }
118
+ .fighter .mark svg { width: 13px; height: 13px; color: #111; opacity: 0; }
119
+ .fighter.on .mark { background: var(--c); }
120
+ .fighter.on .mark svg { opacity: 1; }
121
+ .fighter .lock { position: absolute; top: 18px; right: 18px; width: 20px; height: 20px; color: var(--dim); display: none; }
122
+ .fighter.locked { background: #151517; border-color: #1c1c1f; }
123
+ .fighter.locked .avatar { filter: grayscale(1) brightness(.55); }
124
+ .fighter.locked .fname, .fighter.locked .org, .fighter.locked .tag { color: var(--dim); }
125
+ .fighter.locked .mark { display: none; }
126
+ .fighter.locked .lock { display: block; }
127
+ .fighter .keyline { color: var(--accent); font-size: 15px; margin-top: 2px; }
128
+ .fighter .paste { margin-top: 8px; border: 1px solid #2c2c31; background: #19191c; padding: 3px 7px; font-size: 15px;
129
+ color: var(--text); cursor: pointer; }
130
+ .fighter .paste:hover { border-color: var(--accent); }
131
+ .fighter input, .fighter select { width: 100%; margin-top: 8px; padding: 7px 9px; border: 1px solid #2c2c31; border-radius: 0;
132
+ background: #0d0d0f; color: var(--text); font-size: 12.5px; font-family: var(--mono); }
133
+ .fighter input:focus, .fighter select:focus { outline: none; border-color: var(--accent); }
134
 
135
  /* ---------- play ---------- */
136
+ #pano { position: absolute; inset: 0; }
137
+ .return { position: absolute; top: 32px; left: 34px; z-index: 5; padding: 14px 20px; font-size: 17px; }
138
+ .scoreboard { position: absolute; top: 32px; left: 50%; transform: translateX(-50%); z-index: 5; display: flex; align-items: stretch;
139
+ gap: 12px; background: rgba(17,17,19,.94); padding: 12px; }
140
+ .scoreboard .cell { min-width: 104px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 6px 14px; }
141
+ .scoreboard .cell b { font-size: 36px; font-weight: 500; line-height: 1; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
142
+ .scoreboard .cell small { font-size: 15px; text-transform: uppercase; margin-top: 3px; }
143
+ .scoreboard .round { background: #505055; color: #e9e9ec; }
144
+ .scoreboard .score { background: var(--accent); color: #fff; }
145
+ .scoreboard .timer { min-width: 130px; display: grid; place-items: center; font-size: 40px; font-weight: 500;
146
+ font-variant-numeric: tabular-nums; letter-spacing: -.02em; }
147
  .scoreboard .timer.warn { color: var(--bad); animation: pulse 1s infinite; }
148
+ @keyframes pulse { 50% { opacity: .45; } }
 
149
 
150
+ .rivals { position: absolute; top: 32px; right: 54px; width: 362px; z-index: 5; background: rgba(17,17,19,.95);
151
+ padding: 20px 20px 8px; max-height: calc(100vh - var(--topbar) - 470px); min-height: 120px; overflow-y: auto; }
152
+ .rivals-head { display: flex; justify-content: space-between; align-items: center; font-size: 15px; text-transform: uppercase; margin-bottom: 6px; }
153
+ .live { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 14px; text-transform: none; }
154
+ .live i { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); display: inline-block; }
155
+ .live i:first-child { animation: pulse 1.4s infinite; }
156
+ .rival { padding: 16px 0 14px; }
157
+ .rival .row { display: flex; align-items: center; gap: 12px; font-size: 17px; }
158
+ .rival .row .avatar { --s: 30px; }
159
+ .rival .row .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
160
+ .rival .status { margin-left: auto; font-size: 14px; padding: 2px 6px; background: var(--c); color: #fff; white-space: nowrap; }
161
+ .rival .status.locked { background: var(--good); color: #04220f; }
162
+ .rival .status.out, .rival .status.error { background: var(--bad); color: #fff; }
163
+ .bar { height: 5px; background: #4a4a50; margin: 12px 0 10px; }
164
+ .bar > i { display: block; height: 100%; background: var(--c); width: 0; transition: width .4s; }
165
+ .rival .last { display: flex; gap: 7px; align-items: center; font-size: 14.5px; color: var(--muted); white-space: nowrap; overflow: hidden; }
166
+ .rival .last span { overflow: hidden; text-overflow: ellipsis; }
167
+ .rival .last.flash span { animation: flash .8s; }
 
168
  @keyframes flash { from { color: var(--text); } }
169
+
170
+ /* After the human locks in, the panel widens into a live trace with pictures. */
171
  .rival .feed { display: none; }
172
+ .rivals.unlocked { width: 460px; max-height: calc(100vh - var(--topbar) - 64px); }
173
+ .rivals.unlocked #live-mode { color: var(--accent); }
174
+ .rivals.unlocked .live i:nth-of-type(2) { background: var(--accent); }
175
+ .rivals.unlocked .feed { display: block; max-height: 380px; overflow-y: auto; margin-top: 4px; padding-left: 12px;
176
  border-left: 2px solid var(--c); }
177
+ .thought { padding: 8px 0; font-size: 13px; line-height: 1.45; border-top: 1px solid #222226; }
178
  .thought:first-child { border-top: 0; }
179
+ .thought .tl { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 3px; }
180
  .thought .text { white-space: pre-wrap; }
181
+ .thought.tool .text, .thought.start .text { color: var(--muted); font-size: 12px; }
182
+ .thought details summary { cursor: pointer; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
183
  .thought .thinking { white-space: pre-wrap; color: var(--muted); font-size: 12px; margin: 4px 0; }
184
+ .thought img { display: block; width: 100%; margin-top: 6px; cursor: zoom-in; border: 1px solid #2c2c31; }
185
 
186
+ .guess-box { position: absolute; right: 54px; bottom: 48px; z-index: 6; width: 362px; display: flex; flex-direction: column;
187
+ gap: 12px; transition: width .25s; }
188
+ .guess-box:hover, .guess-box.pinned { width: min(640px, 56vw); }
189
+ #guess-map { height: 318px; border: 1px solid rgba(255,255,255,.15); transition: height .25s; opacity: .9; }
190
+ .guess-box:hover #guess-map, .guess-box.pinned #guess-map { height: min(460px, 52vh); opacity: 1; }
191
+ #guess-btn { width: 100%; padding: 16px; font-size: 22px; }
192
+ .guess-box.pinned #guess-btn { background: var(--accent); border-color: var(--accent); color: #fff; }
193
 
194
+ .locked-banner { position: absolute; left: 50%; bottom: 48px; transform: translateX(-50%); z-index: 7;
195
+ background: rgba(17,17,19,.95); border-left: 4px solid var(--accent); padding: 16px 24px; font-size: 17px; white-space: nowrap; }
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
  /* ---------- reveal ---------- */
198
+ #reveal-map { position: absolute; left: 0; top: 0; bottom: 0; right: 500px; }
199
+ .reveal-panel { position: absolute; right: 0; top: 0; bottom: 0; width: 500px; background: var(--panel);
200
+ border-left: 1px solid #202023; display: flex; flex-direction: column; }
201
+ .eyebrow { font-size: 14px; text-transform: uppercase; letter-spacing: .08em; color: var(--accent); }
202
+ .reveal-head { padding: 28px 28px 18px; border-bottom: 1px solid #202023; }
203
+ .reveal-head h2 { font-weight: 500; margin: 10px 0 6px; font-size: 36px; line-height: 1.05; letter-spacing: -.02em; }
 
 
 
 
204
  .reveal-head h2 em { font-style: normal; color: var(--accent); }
205
+ #round-table, #replay { flex: 1; overflow-y: auto; padding: 14px 20px; }
206
+ .reveal-actions { padding: 16px 20px 20px; border-top: 1px solid #202023; }
207
  .reveal-actions .btn { width: 100%; }
208
 
209
+ .lb { width: 100%; border-collapse: separate; border-spacing: 0 8px; font-size: 15px; }
210
+ .lb th { text-align: left; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .08em;
211
+ font-weight: 500; padding: 2px 12px; }
212
+ .lb td { padding: 12px; background: var(--cell); border-top: 1px solid #222226; border-bottom: 1px solid #222226; }
213
+ .lb td:first-child { border-left: 1px solid #222226; }
214
+ .lb td:last-child { border-right: 1px solid #222226; }
215
  .lb tr.clickable { cursor: pointer; }
216
+ .lb tr.clickable:hover td { border-color: var(--line-2); background: #1e1e21; }
217
+ .lb tr.me td { background: color-mix(in srgb, var(--accent) 10%, var(--cell)); }
218
+ .lb tr.me td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
219
+ .lb .rank { font-size: 20px; color: var(--muted); width: 40px; text-align: center; }
220
+ .lb tbody tr:first-child .rank { color: var(--accent); }
221
+ .lb .pts { font-size: 20px; text-align: right; font-variant-numeric: tabular-nums; }
222
+ .lb .who { display: flex; align-items: center; gap: 10px; }
223
+ .lb .sub { font-size: 13px; color: var(--muted); }
224
+ .lb .chev { text-align: right; width: 70px; }
225
+ .lb .chev span { font-size: 12px; border: 1px solid #2c2c31; padding: 3px 7px; color: var(--muted); white-space: nowrap; }
226
+ .lb tr.clickable:hover .chev span { color: var(--text); border-color: var(--accent); }
227
 
228
+ .replay-head { display: flex; align-items: center; gap: 12px; padding: 4px 0 14px; position: sticky; top: -14px;
229
+ background: var(--panel); z-index: 1; }
230
+ .replay-head b { display: flex; align-items: center; gap: 8px; font-weight: 500; font-size: 17px; }
231
+ .step { border-left: 2px solid #2c2c31; margin: 0 0 0 6px; padding: 0 0 18px 18px; position: relative; }
232
+ .step::before { content: ""; position: absolute; left: -6px; top: 4px; width: 10px; height: 10px; background: var(--c, var(--dim)); }
233
+ .step .tl { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin-bottom: 4px; }
234
+ .step .text { white-space: pre-wrap; font-size: 14px; line-height: 1.5; }
 
235
  .step details { margin-top: 6px; }
236
  .step summary { cursor: pointer; font-size: 12px; color: var(--muted); }
237
+ .step .thinking { white-space: pre-wrap; font-size: 12.5px; color: var(--muted); line-height: 1.5; margin-top: 4px; }
238
+ .call { display: inline-block; font-family: var(--mono); font-size: 12px; background: #1e1e21; border: 1px solid #2c2c31;
239
+ padding: 3px 8px; margin: 6px 4px 0 0; word-break: break-all; }
240
+ .step img { display: block; width: 100%; margin-top: 8px; cursor: zoom-in; border: 1px solid #2c2c31; }
241
+ .verdict { background: var(--cell); border: 1px solid #222226; border-left: 4px solid var(--c, var(--dim)); padding: 16px;
242
+ margin: 0 0 18px; font-size: 15px; }
243
 
244
  /* ---------- final ---------- */
245
+ .final-wrap { max-width: 820px; margin: 0 auto; padding: 56px 24px; text-align: center; }
246
+ .final-wrap h1 { font-weight: 500; font-size: clamp(44px, 7vw, 80px); margin: 14px 0 10px; letter-spacing: -.035em; line-height: 1; }
247
+ .final-wrap > p { font-size: 18px; }
248
+ .podium { display: flex; justify-content: center; align-items: flex-end; gap: 12px; margin: 48px 0 28px; }
249
+ .podium .place { width: 190px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
250
+ .podium .nm { font-size: 17px; }
251
+ .podium .block { width: 100%; display: flex; flex-direction: column; align-items: center; padding-top: 16px; gap: 4px;
252
+ background: var(--panel); border: 1px solid #222226; border-top: 4px solid var(--c); }
253
+ .podium .p1 .block { height: 190px; background: color-mix(in srgb, var(--c) 14%, var(--panel)); }
 
 
 
 
 
254
  .podium .p2 .block { height: 140px; }
255
  .podium .p3 .block { height: 105px; }
256
+ .podium .block .n { font-size: 48px; line-height: 1; }
257
+ .podium .block .pts { color: var(--muted); font-variant-numeric: tabular-nums; }
258
  #final-table { text-align: left; margin-bottom: 32px; }
259
+ #again-btn { min-width: 320px; }
260
 
261
  /* ---------- overlays ---------- */
262
+ .loading { position: fixed; inset: 0; background: rgba(8,8,10,.82); backdrop-filter: blur(4px); z-index: 50; display: flex;
263
+ flex-direction: column; align-items: center; justify-content: center; gap: 18px; font-size: 20px; }
264
+ .spinner { width: 46px; height: 46px; border: 4px solid #2a2a2e; border-top-color: var(--accent); border-radius: 50%;
265
+ animation: spin .8s linear infinite; }
 
266
  @keyframes spin { to { transform: rotate(360deg); } }
267
+ .lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.9); z-index: 60; display: flex; align-items: center;
268
  justify-content: center; cursor: zoom-out; }
269
+ .lightbox img { max-width: 92vw; max-height: 92vh; }
270
+ .modal { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 55; display: grid; place-items: center; padding: 20px; }
271
+ .modal-card { width: min(640px, 100%); background: var(--panel); border: 1px solid #222226; }
272
+ .modal-card .panel-body { padding: 28px 32px; font-size: 16px; line-height: 1.5; color: #d8d8dc; }
273
+ .modal-card ul { padding-left: 20px; margin: 0; }
274
+ .modal-card li { margin-bottom: 8px; }
275
+
276
+ /* ---------- small screens ---------- */
277
+ @media (max-width: 1100px) {
278
+ .lobby-grid { grid-template-columns: minmax(0, 1fr); gap: 32px; }
279
+ .hero { grid-template-columns: minmax(0, 1fr); gap: 14px; }
280
+ }
281
+ @media (max-width: 1000px) {
282
+ #reveal-map { right: 0; bottom: 55vh; }
283
+ .reveal-panel { width: 100%; top: 45vh; border-left: 0; border-top: 1px solid #202023; }
284
+ }
285
+ @media (max-width: 760px) {
286
+ :root { --topbar: 56px; }
287
+ .topbar { padding-right: 14px; }
288
+ .brand { font-size: 18px; }
289
+ .topbar nav { gap: 14px; font-size: 15px; }
290
+ #nav-rules, #nav-traces { display: none; }
291
+ .lobby-wrap { padding: 0 16px 40px; }
292
+ .hero { margin: 0 -16px; padding: 18px 16px; }
293
+ .panel-body { padding: 22px 16px; }
294
+ .stats { grid-template-columns: repeat(2, 1fr); }
295
+ .roster { grid-template-columns: repeat(2, minmax(0, 1fr)); }
296
+ .return { top: auto; bottom: 14px; left: 10px; padding: 10px 12px; font-size: 14px; }
297
+ .scoreboard { top: 10px; padding: 6px; gap: 6px; }
298
+ .scoreboard .cell { min-width: 64px; padding: 4px 8px; }
299
+ .scoreboard .cell b { font-size: 22px; }
300
+ .scoreboard .cell small { font-size: 11px; }
301
+ .scoreboard .timer { min-width: 80px; font-size: 26px; }
302
+ .rivals { top: 90px; left: 10px; right: auto; width: 220px; max-height: 32vh; padding: 10px 12px; }
303
+ .rival .last { display: none; }
304
+ .rivals.unlocked { width: calc(100vw - 20px); max-height: 55vh; }
305
+ .guess-box, .guess-box:hover, .guess-box.pinned { width: calc(100vw - 20px); right: 10px; bottom: 10px; }
306
+ .guess-box:not(.pinned) #guess-map { height: 150px; }
307
+ }