Spaces:
Running
Running
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
- arena/agent.py +7 -2
- arena/config.py +16 -13
- arena/game.py +2 -2
- arena/main.py +7 -5
- static/app.js +114 -64
- static/index.html +73 -38
- 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":
|
|
|
|
|
|
|
| 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/
|
|
|
|
|
|
|
| 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
|
| 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 |
-
"""
|
| 206 |
if not 1 <= round_number <= len(self.rounds):
|
| 207 |
return None
|
| 208 |
rnd = self.rounds[round_number - 1]
|
| 209 |
-
if
|
| 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,
|
|
|
|
| 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(
|
| 94 |
-
|
|
|
|
| 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 (
|
| 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 = "#
|
| 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 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
: `<a
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 && `
|
| 101 |
-
byokModels.length && `
|
| 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 = () =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
nameInput.value = localStorage.getItem("arena.name") || hfUser?.username || "";
|
| 107 |
nameInput.oninput = syncAvatar;
|
|
|
|
| 108 |
syncAvatar();
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
`
|
| 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.
|
| 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
|
| 130 |
-
|
| 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 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
| 138 |
el.style.setProperty("--c", m.color);
|
| 139 |
-
const tag =
|
| 140 |
-
el.innerHTML = `<span class="
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
if (m.byok) {
|
|
|
|
|
|
|
| 143 |
const input = document.createElement("input");
|
| 144 |
-
input.type = "password"; input.placeholder = "
|
|
|
|
| 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 |
-
|
| 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 (
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 304 |
$("#guess-btn").onclick = submitGuess;
|
| 305 |
|
| 306 |
renderRivals(rnd.agents);
|
| 307 |
$("#rivals").classList.remove("unlocked");
|
| 308 |
-
$("#
|
| 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, "
|
| 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" ? "
|
| 375 |
<div class="feed"></div>`;
|
| 376 |
list.appendChild(el);
|
| 377 |
});
|
| 378 |
}
|
| 379 |
function statusText(s) {
|
| 380 |
-
return { thinking: "
|
| 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 = `${
|
| 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 = `
|
| 402 |
} else if (ev.status === "error") {
|
| 403 |
-
el.querySelector(".last").textContent = "
|
| 404 |
} else if (ev.status === "out") {
|
| 405 |
-
el.querySelector(".last").textContent = "
|
| 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 |
-
$("#
|
| 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 |
-
|
| 439 |
-
el.
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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="
|
| 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) =>
|
| 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="
|
| 561 |
} else if (e.role === "assistant") {
|
| 562 |
const calls = callChips(e.tool_calls);
|
| 563 |
-
html += `<div class="step" style="--c:${color}"><div class="
|
| 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="
|
| 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 ? "
|
| 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>
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
-
<link href="https://fonts.googleapis.com/css2?family=
|
| 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 |
-
<
|
| 17 |
-
<span class="
|
| 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
|
| 21 |
-
</
|
| 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 |
-
|
| 37 |
-
|
| 38 |
-
<
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
</div>
|
| 41 |
-
</div>
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"><
|
| 56 |
<div class="timer" id="hud-timer">3:00</div>
|
| 57 |
-
<div class="cell"><
|
| 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">
|
| 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
|
| 70 |
</div>
|
| 71 |
|
| 72 |
<div id="locked-banner" class="locked-banner hidden">
|
| 73 |
-
|
| 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
|
| 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
|
| 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
|
| 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: #
|
| 3 |
-
--
|
| 4 |
-
--
|
| 5 |
-
--
|
| 6 |
-
--
|
| 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 |
-
|
| 25 |
-
button, input { font: inherit; }
|
| 26 |
-
|
| 27 |
-
.small { font-size: 12px; }
|
| 28 |
.hidden { display: none !important; }
|
| 29 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
|
| 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 {
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
.btn:disabled {
|
| 44 |
-
.btn.primary { background: var(--accent); color:
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
.btn.
|
| 48 |
-
.btn.
|
| 49 |
-
.btn.
|
| 50 |
-
|
| 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:
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 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.
|
| 63 |
-
.avatar.
|
| 64 |
-
.avatar.
|
|
|
|
|
|
|
| 65 |
|
| 66 |
/* ---------- lobby ---------- */
|
| 67 |
-
.lobby-wrap { max-width:
|
| 68 |
-
.
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
.
|
| 73 |
-
|
| 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 |
-
.
|
| 81 |
-
|
| 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 |
-
.
|
| 99 |
-
|
| 100 |
-
.
|
| 101 |
-
|
| 102 |
-
.
|
| 103 |
-
|
| 104 |
-
.
|
| 105 |
-
|
| 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 |
-
.
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
.
|
| 118 |
-
.
|
| 119 |
-
.
|
| 120 |
-
|
| 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 |
-
.
|
| 127 |
-
.
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
.
|
| 131 |
-
.
|
| 132 |
-
.
|
| 133 |
-
|
| 134 |
-
.
|
| 135 |
-
.
|
| 136 |
-
.
|
| 137 |
-
.
|
| 138 |
-
.
|
| 139 |
-
.
|
| 140 |
-
.
|
| 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 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
/* ---------- play ---------- */
|
| 155 |
-
#pano { position: absolute; inset: 0;
|
| 156 |
-
.
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
.scoreboard .cell {
|
| 160 |
-
.scoreboard .cell
|
| 161 |
-
.scoreboard .cell
|
| 162 |
-
.scoreboard .
|
| 163 |
-
|
| 164 |
-
|
|
|
|
| 165 |
.scoreboard .timer.warn { color: var(--bad); animation: pulse 1s infinite; }
|
| 166 |
-
@keyframes pulse { 50% { opacity: .
|
| 167 |
-
.return { position: absolute; top: 14px; left: 14px; z-index: 5; }
|
| 168 |
|
| 169 |
-
.rivals { position: absolute; top:
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
.
|
| 173 |
-
|
| 174 |
-
.live
|
| 175 |
-
.
|
| 176 |
-
.rival {
|
| 177 |
-
.rival
|
| 178 |
-
.rival .row {
|
| 179 |
-
.rival .status { margin-left: auto; font-size:
|
| 180 |
-
|
| 181 |
-
.rival .status.
|
| 182 |
-
.
|
| 183 |
-
.
|
| 184 |
-
.
|
| 185 |
-
.
|
| 186 |
-
.rival .last {
|
| 187 |
-
.rival .last.flash { animation: flash .8s; }
|
| 188 |
@keyframes flash { from { color: var(--text); } }
|
| 189 |
-
|
|
|
|
| 190 |
.rival .feed { display: none; }
|
| 191 |
-
.rivals.unlocked { width:
|
| 192 |
-
.rivals.unlocked
|
| 193 |
-
.rivals.unlocked .live:
|
| 194 |
-
.rivals.unlocked .feed { display: block; max-height:
|
| 195 |
border-left: 2px solid var(--c); }
|
| 196 |
-
.thought { padding:
|
| 197 |
.thought:first-child { border-top: 0; }
|
|
|
|
| 198 |
.thought .text { white-space: pre-wrap; }
|
| 199 |
-
.thought
|
|
|
|
| 200 |
.thought .thinking { white-space: pre-wrap; color: var(--muted); font-size: 12px; margin: 4px 0; }
|
| 201 |
-
.thought
|
| 202 |
|
| 203 |
-
.guess-box { position: absolute; right:
|
| 204 |
-
|
| 205 |
-
.guess-box:hover, .guess-box.pinned { width: min(640px,
|
| 206 |
-
#guess-map { height:
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
#guess-btn {
|
| 210 |
|
| 211 |
-
.locked-banner { position: absolute; left: 50%; bottom:
|
| 212 |
-
background:
|
| 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:
|
| 229 |
-
.reveal-panel { position: absolute; right: 0; top: 0; bottom: 0; width:
|
| 230 |
-
border-left: 1px solid
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 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:
|
| 240 |
-
.reveal-actions { padding: 16px
|
| 241 |
.reveal-actions .btn { width: 100%; }
|
| 242 |
|
| 243 |
-
.lb { width: 100%; border-collapse: separate; border-spacing: 0
|
| 244 |
-
.lb th { text-align: left; font-size:
|
| 245 |
-
font-weight:
|
| 246 |
-
.lb td { padding:
|
| 247 |
-
.lb td:first-child { border-
|
| 248 |
-
.lb td:last-child { border-
|
| 249 |
.lb tr.clickable { cursor: pointer; }
|
| 250 |
-
.lb tr.clickable:hover td {
|
| 251 |
-
.lb tr.me td { background: color-mix(in srgb, var(--
|
| 252 |
-
.lb tr.me td:first-child { box-shadow: inset 3px 0 0 var(--
|
| 253 |
-
.lb .rank { font-
|
| 254 |
-
.lb tr:
|
| 255 |
-
.lb .pts { font-
|
| 256 |
-
.lb .who { display: flex; align-items: center; gap:
|
| 257 |
-
.lb .sub { font-size:
|
| 258 |
-
.lb .chev {
|
|
|
|
|
|
|
| 259 |
|
| 260 |
-
.replay-head { display: flex; align-items: center; gap:
|
| 261 |
-
background: var(--panel
|
| 262 |
-
.replay-head b { display: flex; align-items: center; gap: 8px; }
|
| 263 |
-
.step { border-left: 2px solid
|
| 264 |
-
.step::before { content: ""; position: absolute; left: -6px; top:
|
| 265 |
-
|
| 266 |
-
.step .
|
| 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:
|
| 271 |
-
.call { display: inline-block; font-family: var(--mono); font-size: 12px; background:
|
| 272 |
-
|
| 273 |
-
.step img { display: block; width: 100%;
|
| 274 |
-
.verdict { background:
|
| 275 |
-
|
| 276 |
|
| 277 |
/* ---------- final ---------- */
|
| 278 |
-
.final-wrap { max-width:
|
| 279 |
-
.final-wrap h1 { font-
|
| 280 |
-
|
| 281 |
-
.
|
| 282 |
-
.podium { display: flex;
|
| 283 |
-
.podium .
|
| 284 |
-
.podium .
|
| 285 |
-
|
| 286 |
-
|
| 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(
|
| 298 |
-
flex-direction: column; align-items: center; justify-content: center; gap: 18px;
|
| 299 |
-
|
| 300 |
-
|
| 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,.
|
| 304 |
justify-content: center; cursor: zoom-out; }
|
| 305 |
-
.lightbox img { max-width: 92vw; max-height: 92vh;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|