metrollm / app.py
Remco Hendriks
demo-v3: reserve mobile bottom space for Play now pill
9c1ad86
"""MetroLLM-Bench demo v3 — Gradio + ZeroGPU rebuild.
Build is incremental and verified at each step:
1. UI shell: scenarios, custom tab, result chatbot. No model.
2. Model load + one-round @spaces.GPU generate.
3. (this commit) Multi-round agent loop with in-process tool dispatch.
4. Streaming reasoning + structured tool-call rendering.
5. Leaflet map with route polyline.
"""
from __future__ import annotations
import html
import json
import os
import re
import sys
import time
import uuid
from pathlib import Path
import gradio as gr
# Monorepo: harness/, data/, dashboard/ live at HERE.parent.
# Space deploy: pack_for_space.sh copies them next to app.py, so HERE itself.
HERE = Path(__file__).resolve().parent
REPO_ROOT = HERE if (HERE / "harness").is_dir() else HERE.parent
if str(HERE) not in sys.path:
sys.path.insert(0, str(HERE))
import prompts # type: ignore # noqa: E402
# --- Spaces decorator stub for local dev -----------------------------------
# The `spaces` package only exists on HF infra. On local dev we use a no-op
# decorator so the module imports cleanly without it.
try:
import spaces # type: ignore
GPU = spaces.GPU
except Exception:
class _NoopGPU:
def __call__(self, *args, **kwargs):
if args and callable(args[0]):
return args[0]
def deco(fn):
return fn
return deco
GPU = _NoopGPU()
DEFAULT_SYSTEM = os.environ.get("SYSTEM_NAME", "bart")
prompts.validate_slug(DEFAULT_SYSTEM)
KNOWN_SYSTEMS = prompts.known_systems()
SYSTEM_INFO = {
# bg/fg are the active-card fill colour and the readable foreground on
# top of it. bg comes from each system's own lines.json (one line picked
# per system to give distinct colours across the row, not all-red).
"marta": {"city": "Atlanta", "country": "USA", "system": "MARTA",
"bg": "#0060A9", "fg": "#fff"}, # Blue Line
"bart": {"city": "San Francisco", "country": "USA", "system": "BART",
"bg": "#F58220", "fg": "#fff"}, # Orange Line
"cta": {"city": "Chicago", "country": "USA", "system": "CTA",
"bg": "#C60C30", "fg": "#fff"}, # Red Line
"doha": {"city": "Doha", "country": "Qatar", "system": "Metro",
"bg": "#FFD700", "fg": "#000"}, # Gold Line (bright → dark text)
"taipei": {"city": "Taipei", "country": "Taiwan", "system": "MRT",
"bg": "#0070BD", "fg": "#fff"}, # Bannan Line
"beijing": {"city": "Beijing", "country": "China", "system": "Subway",
"bg": "#A4343A", "fg": "#fff"}, # Line 1
}
# Single-line label kept for places that still want a one-liner (page title, …).
SYSTEM_LABELS = {s: f"{i['city']}: {i['system']}" for s, i in SYSTEM_INFO.items()}
def _system_card_label(slug: str) -> str:
"""Two-line label for system-card buttons. CSS ::first-line styles
the system name; line 2 (city, country) inherits the smaller mono."""
info = SYSTEM_INFO.get(slug) or {"city": slug.title(), "country": "", "system": slug.title()}
loc = ", ".join(p for p in (info.get("city"), info.get("country")) if p)
return f"{info['system']}\n{loc}"
def _load_geo(system: str) -> dict:
"""Slice of dashboard/verify_data.json for the active system."""
p = REPO_ROOT / "dashboard" / "verify_data.json"
if not p.exists():
return {"stations": [], "edges": [], "lines": []}
raw = (__import__("json").loads(p.read_text())).get(system) or {}
return {
"stations": [
{"id": s["id"], "name": s.get("name", s["id"]),
"lat": s.get("lat"), "lon": s.get("lon"),
"lines": s.get("lines", [])}
for s in raw.get("stations", [])
],
"edges": [
{"from": e["from"], "to": e["to"], "line": e.get("line", "")}
for e in raw.get("edges", [])
],
"lines": raw.get("lines", []),
}
import model as mdl # noqa: E402 (heavy import; lands once at module load)
import tools # noqa: E402
# --- Site header ------------------------------------------------------------
REPO_URL = os.environ.get("REPO_URL", "")
PAPER_URL = os.environ.get("PAPER_URL", "")
# Mean Tier-1 across 6 systems, n=2 seeds, v23. Source: research_v3 / memory.
_T1_BY_SIZE = {
"2B": "82.4",
"4B": "91.0",
"9B": "92.5",
"27B": "90.4",
}
def _model_size(model_id: str) -> str:
m = re.search(r"-(\d+(?:\.\d+)?)B\b", model_id)
return f"{m.group(1)}B" if m else "—"
def _build_site_header() -> str:
base = mdl.MODEL_ID
adapter = mdl.ADAPTER_ID or ""
size = _model_size(base)
t1 = _T1_BY_SIZE.get(size, "—")
model_link = f"https://huggingface.co/{adapter}" if adapter else "#"
paper_attr = "" if PAPER_URL else ' data-placeholder="true"'
repo_attr = "" if REPO_URL else ' data-placeholder="true"'
return f"""
<div id="site-header">
<div class="site-title">MetroLLM-Bench</div>
<div class="site-subtitle">
Interactive transit-kiosk companion to the MetroLLM-Bench paper. Pick a
metro and a scenario, then watch a fine-tuned LLM call tools to plan
your trip, across six real systems, reasoning with disruptions and
edge cases.
</div>
<div class="site-meta">
<a class="meta-link" href="{PAPER_URL or '#'}"{paper_attr}>paper</a>
<a class="meta-link" href="{REPO_URL or '#'}"{repo_attr}>repository</a>
<a class="meta-link" href="{model_link}" target="_blank" rel="noopener">model</a>
</div>
<div class="site-specs">
<span class="spec"><span class="spec-key">base</span>{base}</span>
<span class="spec"><span class="spec-key">params</span>{size}</span>
<span class="spec"><span class="spec-key">peft</span>LoRA r=16, v23</span>
<span class="spec"><span class="spec-key">bench T1</span>{t1}%</span>
</div>
</div>
"""
SITE_HEADER_HTML = _build_site_header()
def _build_site_footer() -> str:
base = mdl.MODEL_ID
adapter = mdl.ADAPTER_ID or ""
size = _model_size(base)
model_link = f"https://huggingface.co/{adapter}" if adapter else ""
paper_attr = "" if PAPER_URL else ' data-placeholder="true"'
repo_attr = "" if REPO_URL else ' data-placeholder="true"'
if model_link:
attribution = (
f'<a class="footer-link footer-attribution-link" '
f'href="{model_link}" target="_blank" rel="noopener">'
f'Qwen3.5-{size} + LoRA v23</a>'
)
else:
attribution = f"Qwen3.5-{size} + LoRA v23"
return f"""
<div id="site-footer">
<div class="footer-meta">
<a class="footer-link" href="{PAPER_URL or '#'}"{paper_attr}>paper</a>
<a class="footer-link" href="{REPO_URL or '#'}"{repo_attr}>repository</a>
<span class="footer-attribution">Powered by {attribution}</span>
</div>
<div class="footer-credit">MetroLLM-Bench · interactive demo · 2026</div>
</div>
"""
SITE_FOOTER_HTML = _build_site_footer()
def _system_bundle(system: str) -> dict:
"""All UI-relevant data for a system: stations, lines, events,
scenarios, geo, system prompt, and a station_id lookup."""
geo = _load_geo(system)
return {
"slug": system,
"name": SYSTEM_LABELS.get(system, system.title()),
"stations": prompts.load_stations(system),
"lines": prompts.load_lines(system),
"events": prompts.load_events(system),
"scenarios": prompts.load_scenarios(system),
"geo": geo,
"station_by_id": {s["id"]: s for s in geo["stations"]},
}
# Pre-load every available system at startup so switching is just a state
# update — no repeat file reads, no re-tokenization.
SYSTEMS = {s: _system_bundle(s) for s in KNOWN_SYSTEMS}
print(f"[app] loaded systems: {', '.join(SYSTEMS.keys())}", flush=True)
print(f"[app] mock_server in-process: {'ok' if tools.health() else 'unreachable'}",
flush=True)
MAX_ROUNDS = int(os.environ.get("MAX_ROUNDS", "10"))
# Animated three-dot placeholder shown while the model is generating but
# narrative content hasn't surfaced yet (e.g. inside a <think> block).
# CSS in CUSTOM_CSS animates the .loading-dots span via opacity pulse so
# the user sees a living "thinking" cue instead of a static "…".
_LOADING_DOTS = '<span class="loading-dots">…</span>'
# Pure-function rendering helpers — extracted to render.py so the capture
# tooling (scripts/capture_replay.py --llama-server …) can format tool
# cards + route stops without importing app.py (which triggers model
# load). Aliased back to the original _-prefixed names so all call-sites
# below remain unchanged.
from render import ( # noqa: E402
KNOWN_TOOLS,
strip_think_block as _strip_think_block,
tool_status as _tool_status,
summarise_result as _summarise_result,
summarise_args as _summarise_args,
format_tool_card as _format_tool_card,
route_with_coords as _route_with_coords,
)
@GPU(duration=120)
def _generate_one_round(messages: list[dict]) -> str:
"""Blocking single-round generate. Kept as a fallback path."""
return mdl.generate_one_round(messages)
@GPU(duration=120)
def _stream_one_round(messages: list[dict]):
"""Generator: yields (chunk, accumulated_full_text) per token batch.
The @GPU decorator holds the GPU for the generator's lifetime — fine for
a single round (~30s on A100). Multi-round budgeting still happens in
the outer agent loop where each round is its own GPU call."""
yield from mdl.stream_one_round(messages)
# --- Map JS ----------------------------------------------------------------
# Leaflet is loaded via the `head=` injection on launch. Init runs once on
# page load (we poll for the #metro-map div to exist because Gradio's Svelte
# runtime mounts it after demo.load fires). The route is pushed from Python
# via a hidden `gr.JSON()` state — its .change handler redraws the polyline.
_DEAD_INIT_JS = """
() => {
const init = () => {
const el = document.getElementById('metro-map');
if (!el) { setTimeout(init, 100); return; }
if (el._leafletReady) return;
if (typeof L === 'undefined') { setTimeout(init, 100); return; }
el._leafletReady = true;
const map = L.map('metro-map', { zoomControl: true, scrollWheelZoom: false });
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OSM © CARTO', maxZoom: 18,
}).addTo(map);
const geo = window.METRO_GEO || {stations: [], edges: [], lines: []};
const sById = {};
for (const s of geo.stations) sById[s.id] = s;
const lineColor = {};
for (const l of geo.lines) lineColor[l.id || l.name?.toLowerCase()] = l.color || '#999';
// Network background — semi-transparent line segments
for (const e of geo.edges) {
const a = sById[e.from], b = sById[e.to];
if (!a || !b || a.lat == null || b.lat == null) continue;
const c = lineColor[(e.line || '').toLowerCase()] || '#999';
L.polyline([[a.lat, a.lon], [b.lat, b.lon]], {
color: c, weight: 3, opacity: 0.45,
}).addTo(map);
}
for (const s of geo.stations) {
if (s.lat == null) continue;
L.circleMarker([s.lat, s.lon], {
radius: 3, color: '#666', fillColor: '#fff', fillOpacity: 1,
weight: 1.5,
}).addTo(map).bindTooltip(s.name);
}
// Fit to network bounds
const lats = geo.stations.map(s => s.lat).filter(v => v != null);
const lons = geo.stations.map(s => s.lon).filter(v => v != null);
if (lats.length && lons.length) {
map.fitBounds([[Math.min(...lats), Math.min(...lons)],
[Math.max(...lats), Math.max(...lons)]],
{padding: [16, 16]});
}
window._metroMap = map;
window._routeLayer = null;
window._endpointLayers = [];
};
init();
}
"""
DRAW_ROUTE_JS = """
(route) => {
const map = window._metroMap;
if (!map || !Array.isArray(route)) return;
// Drain prior route layer (everything goes into one LayerGroup so we
// can wipe in a single removeLayer call).
if (window._routeLayer) { map.removeLayer(window._routeLayer); window._routeLayer = null; }
const stops = route.filter(s => s && s.lat != null && s.lon != null);
if (stops.length < 2) return;
// Per-system line colours + names from METRO_GEO so each segment of
// the trip is drawn in its actual line colour. Mirrors the
// /simulator dashboard's drawRoute().
const geo = window.METRO_GEO || { lines: [] };
const lineColor = {};
const lineName = {};
for (const l of (geo.lines || [])) {
const k = (l.id || (l.name || '').toLowerCase());
lineColor[k] = l.color || '#888';
lineName[k] = l.name || k;
}
const colorFor = (line) => lineColor[(line || '').toLowerCase()] || '#888';
const nameFor = (line) => lineName[(line || '').toLowerCase()] || (line || '');
// Group stops into per-line segments. The transfer station gets
// copied into BOTH adjacent segments so the polyline bends visibly
// through the transfer point.
const segs = [];
let cur = null;
for (const s of stops) {
if (!cur || s.line !== cur.line) {
const transfer = cur ? cur.stops[cur.stops.length - 1] : null;
cur = { line: s.line, stops: [] };
segs.push(cur);
if (transfer && transfer.is_transfer) cur.stops.push(transfer);
}
cur.stops.push(s);
}
const layer = L.layerGroup().addTo(map);
const allPts = [];
for (const seg of segs) {
const pts = seg.stops.map(s => [s.lat, s.lon]);
if (pts.length < 2) { allPts.push(...pts); continue; }
const c = colorFor(seg.line);
// White halo behind the coloured line for legibility against
// dense network polylines.
L.polyline(pts, { color: '#fff', weight: 9, opacity: 0.9, interactive: false }).addTo(layer);
L.polyline(pts, { color: c, weight: 5, opacity: 0.95, interactive: false }).addTo(layer);
allPts.push(...pts);
}
const first = stops[0], last = stops[stops.length - 1];
// Origin (green) + Destination (red) endpoint markers.
L.circleMarker([first.lat, first.lon], {
radius: 8, color: '#198038', fillColor: '#42BE65', fillOpacity: 1, weight: 3,
interactive: true
}).addTo(layer).bindTooltip(
'Start: ' + (first.name || first.station_id || ''),
{ direction: 'auto', offset: [10, 0] }
);
L.circleMarker([last.lat, last.lon], {
radius: 8, color: '#DA1E28', fillColor: '#FA4D56', fillOpacity: 1, weight: 3,
interactive: true
}).addTo(layer).bindTooltip(
'End: ' + (last.name || last.station_id || ''),
{ direction: 'auto', offset: [10, 0] }
);
// Transfer markers (orange) — one per unique transfer station, with
// a tooltip explaining the line change.
const seen = new Set();
let prevLine = first.line;
for (let i = 1; i < stops.length; i++) {
const s = stops[i];
if (!s.is_transfer) { prevLine = s.line; continue; }
if (s.station_id === first.station_id || s.station_id === last.station_id) continue;
if (seen.has(s.station_id)) { prevLine = s.line; continue; }
seen.add(s.station_id);
const fromN = nameFor(prevLine), toN = nameFor(s.line);
const tip = (fromN && toN && fromN !== toN)
? 'Transfer: ' + (s.name || s.station_id) + ' (' + fromN + ' → ' + toN + ')'
: 'Transfer: ' + (s.name || s.station_id);
L.circleMarker([s.lat, s.lon], {
radius: 6, color: '#F1C21B', fillColor: '#FFD23F', fillOpacity: 1, weight: 2,
interactive: true
}).addTo(layer).bindTooltip(tip, { direction: 'auto', offset: [10, 0] });
prevLine = s.line;
}
window._routeLayer = layer;
if (allPts.length >= 2) {
map.fitBounds(L.latLngBounds(allPts).pad(0.15));
}
// Mobile: when a route is freshly drawn, scroll the map into view so
// users see the result instead of staying parked at the chat. Brief
// delay so the fitBounds animation can settle. Skipped on desktop
// where the map is always visible side-by-side with the chat.
if (window.innerWidth <= 800) {
setTimeout(() => {
const wrap = document.getElementById('metro-map-wrap');
if (wrap) wrap.scrollIntoView({behavior: 'smooth', block: 'start'});
}, 250);
}
}
"""
# kiosk_state.change handler — renders fare + advisory + action banners under
# the map. Bound JS instead of Python so we don't pay a server round-trip
# just to ship HTML.
RENDER_BANNERS_JS = """
(payload) => {
const el = document.getElementById('kiosk-display');
if (!el) return;
// Clear when payload is null (start of run, system switch).
if (!payload) {
el.innerHTML = '';
el.classList.remove('has-banners');
return;
}
const esc = (s) => String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const parts = [];
// Kiosk message — passenger-facing prose, rendered first as the
// headline. Different typography from the structured cards below
// (Plex Sans, larger, comfortable line-height).
const msg = (payload.message || '').trim();
if (msg) {
parts.push(
'<div class="kiosk-card kiosk-message">' +
'<div class="kiosk-message-body">' + esc(msg) + '</div>' +
'</div>'
);
}
// Fare card
const fare = payload.fare;
if (fare && fare.total != null) {
const total = (typeof fare.total === 'number') ? fare.total.toFixed(2) : esc(String(fare.total));
const currency = esc(fare.currency || '');
const psum = fare.passenger_summary || {};
const paxParts = [];
for (const k in psum) {
const v = psum[k];
if (v && v > 0) paxParts.push(v + '× ' + k);
}
const pax = paxParts.join(', ');
parts.push(
'<div class="kiosk-card kiosk-fare">' +
'<div class="kiosk-card-key">FARE</div>' +
'<div class="kiosk-fare-total">' + currency + ' ' + total + '</div>' +
(pax ? '<div class="kiosk-fare-pax">' + esc(pax) + '</div>' : '') +
'</div>'
);
}
// Advisory cards (severity drives the left rule colour)
const advisories = payload.advisories || [];
for (const a of advisories) {
const sev = String(a.severity || 'info').toLowerCase();
parts.push(
'<div class="kiosk-card kiosk-advisory kiosk-sev-' + esc(sev) + '">' +
'<div class="kiosk-card-key">' + esc(sev.toUpperCase()) + '</div>' +
'<div class="kiosk-advisory-title">' + esc(a.title || '') + '</div>' +
(a.body ? '<div class="kiosk-advisory-body">' + esc(a.body) + '</div>' : '') +
'</div>'
);
}
// Action card (only emit for the actions that imply a passenger gesture;
// display_info is purely informational and doesn't need its own card).
const action = payload.action;
const actionMap = {
'prompt_purchase': {label: 'Tap to purchase', cls: 'kiosk-action-purchase'},
'block_purchase': {label: 'Cannot purchase', cls: 'kiosk-action-block'},
'refer_to_staff': {label: 'Please refer to staff', cls: 'kiosk-action-staff'},
};
if (action && action.action && actionMap[action.action]) {
const a = actionMap[action.action];
parts.push(
'<div class="kiosk-card kiosk-action ' + a.cls + '">' +
'<div class="kiosk-action-label">' + esc(a.label) + '</div>' +
'</div>'
);
}
el.innerHTML = parts.join('');
if (parts.length) {
el.classList.add('has-banners');
// Mobile: scroll the kiosk display into view so the user sees the
// resolution without hunting below the fold (chat is above the map
// in single-column layout, and the map alone is already 240px tall).
if (window.matchMedia('(max-width: 800px)').matches) {
setTimeout(() => el.scrollIntoView({behavior: 'smooth', block: 'start'}), 100);
}
} else {
el.classList.remove('has-banners');
}
}
"""
# Lock the rollout-triggering buttons (scenario cards, plan-trip,
# system selectors) for the duration of a rollout. CSS for body.kiosk-running
# turns them non-interactive; this just toggles the class.
# 5-minute safety net auto-unlock guards against the .then(unlock) step
# never firing (e.g. if _run_agent raises mid-stream).
LOCK_RUNNING_JS = """() => {
document.body.classList.add('kiosk-running');
if (window._kioskUnlockTimer) clearTimeout(window._kioskUnlockTimer);
window._kioskUnlockTimer = setTimeout(() => {
document.body.classList.remove('kiosk-running');
}, 300000);
}"""
UNLOCK_RUNNING_JS = """() => {
document.body.classList.remove('kiosk-running');
if (window._kioskUnlockTimer) {
clearTimeout(window._kioskUnlockTimer);
window._kioskUnlockTimer = null;
}
}"""
# Mobile: when the Output tab becomes active (via Plan click → tab-flip
# chain, or a manual tap), scroll the chat into view. On desktop the
# layout is two columns side by side so the chat is always visible —
# scrolling there would be jarring. Reads the live aria-selected DOM
# state after a short delay so the tab swap has settled.
SCROLL_TO_OUTPUT_JS = """() => {
if (window.innerWidth > 800) return;
setTimeout(() => {
const tabs = document.querySelectorAll('[role="tab"]');
const sel = Array.from(tabs).find(t => t.getAttribute('aria-selected') === 'true');
if (!sel || (sel.textContent || '').trim() !== 'Output') return;
// Scroll the whole controls column into view so the tabs strip
// (showing which tab is active) is visible above the chat.
const target = document.getElementById('kiosk-controls')
|| document.getElementById('chat-thread');
if (target) target.scrollIntoView({behavior: 'smooth', block: 'start'});
}, 150);
}"""
# --- CSS -------------------------------------------------------------------
# Flat color blocks, IBM Plex Sans, no rounding/shadows, no borders. We use
# elem_id/elem_classes everywhere so the rules are stable across Gradio
# versions.
CUSTOM_CSS = """
/* ---------- Tokens ----------
Most colors come from KIOSK_THEME (Gradio auto-swaps _dark variants).
Only multi-level surfaces and brand tokens that Gradio doesn't expose
need a manual .dark override. */
.gradio-container {
--surface: var(--body-background-fill);
--ink: var(--body-text-color);
--ink-soft: var(--body-text-color-subdued);
--rule: var(--border-color-primary);
--surface-2: #ECEEF1;
--surface-3: #DEE2E6;
--ink-faint: #6A737D;
--code-bg: #F2F4F8;
--accent: #C21924;
--warning: #F1C21B;
--positive: #198038;
}
.dark .gradio-container,
.gradio-container.dark,
body.dark .gradio-container {
--surface-2: #1E2227;
--surface-3: #2A3038;
--ink-faint: #767D87;
--code-bg: #1B1F24;
--accent: #FA4D56;
}
/* ---------- Container layout ----------
Gradio's own .svelte-* class sets a fixed width on .gradio-container that
beats our max-width. Force width: 100% so it expands to the viewport and
max-width can actually cap it. */
.gradio-container {
width: 100% !important;
max-width: 1440px !important;
margin: 0 auto !important;
padding: 24px 32px !important;
box-sizing: border-box !important;
}
#kiosk-app { max-width: 1440px; margin: 0 auto; }
/* Flatten Gradio's per-block chrome inside the kiosk app — we draw our own
borders only where wanted (chat, map, scenario cards). */
#kiosk-app .block,
#kiosk-app .form,
#kiosk-app .panel,
#kiosk-app .wrap,
#kiosk-app .secondary-wrap,
#kiosk-app .container,
#kiosk-app .html-container,
#kiosk-app .prose.gradio-style {
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 !important;
gap: 0 !important;
}
/* gr.HTML inserts <a> styling via .prose; reset for our meta links */
#site-header .site-meta a,
#site-footer .footer-link {
padding: 0 !important;
margin: 0 !important;
}
/* Children of the kiosk-app column / row should stretch to the full width
so dropdowns and chat align flush with the title/rules above them. */
#kiosk-app > .block,
#kiosk-app > .form,
#kiosk-header > .block,
#kiosk-header > .form {
width: 100% !important;
flex: 1 1 100% !important;
min-width: 0 !important;
}
#kiosk-app .gradio-container .form { width: 100% !important; }
/* ---------- Site header (title + tagline + meta + specs) ---------- */
#site-header {
padding: 12px 0 18px 0;
margin-bottom: 4px;
border-bottom: 4px double var(--rule);
}
.site-title {
font-family: 'Tektur', 'IBM Plex Sans', system-ui, sans-serif;
font-size: 2.1rem;
font-weight: 600;
/* Tektur is geometric/squarish — neutral tracking reads cleaner than
the negative letter-spacing IBM Plex wanted. */
letter-spacing: 0.005em;
line-height: 1.05;
color: var(--ink);
margin: 0 0 6px 0;
/* Wider glyphs than Plex; clamp prevents overflow on very narrow
viewports without needing a separate media-query branch. */
max-width: 100%;
overflow-wrap: break-word;
}
.site-subtitle {
font-size: 0.95rem;
line-height: 1.45;
color: var(--ink-soft);
margin: 0 0 14px 0;
max-width: 720px;
}
.site-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
margin: 0 0 12px 0;
}
.meta-link {
color: var(--ink) !important;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 1.5px;
font-weight: 600;
}
.meta-link:hover { color: var(--accent) !important; }
.meta-link[data-placeholder="true"]::after {
content: " (soon)";
color: var(--ink-faint);
font-size: 0.68rem;
letter-spacing: 1px;
}
.meta-link[data-placeholder="true"] {
color: var(--ink-faint) !important;
cursor: default;
text-decoration-style: dashed;
}
.site-specs {
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace !important;
font-size: 0.75rem;
color: var(--ink-soft);
}
.site-specs .spec-key {
color: var(--ink-faint);
text-transform: uppercase;
letter-spacing: 1.2px;
margin-right: 6px;
}
/* ---------- System selector row ---------- */
#kiosk-header {
padding: 14px 0 18px 0 !important;
margin: 0 0 14px 0 !important;
align-items: stretch !important;
border-bottom: 1px solid var(--rule);
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap !important;
gap: 8px !important;
}
#kiosk-header > .block,
#kiosk-header > .form { flex: 1 1 auto !important; }
/* System cards — one per metro, two-line label.
Line 1 (system name) is bigger via ::first-line; line 2 (city, country)
inherits the smaller mono. Active card flips to inverse fill. */
.system-card {
background: var(--surface-2) !important;
color: var(--ink) !important;
border: 1px solid var(--rule) !important;
border-radius: 0 !important;
padding: 8px 12px !important;
margin: 0 !important;
cursor: pointer !important;
display: flex !important;
flex-direction: column !important;
align-items: flex-start !important;
justify-content: center !important;
text-align: left !important;
white-space: pre-line !important;
line-height: 1.45 !important;
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace !important;
font-size: 0.7rem !important;
text-transform: uppercase !important;
letter-spacing: 1.2px !important;
font-weight: 500 !important;
min-height: unset !important;
box-shadow: none !important;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease !important;
}
.system-card::first-line {
font-size: 0.88rem;
font-weight: 600;
letter-spacing: 1.6px;
}
.system-card:hover {
background: var(--surface-3) !important;
}
.system-card.system-card-active {
background: var(--ink) !important;
color: var(--surface) !important;
border-color: var(--ink) !important;
}
.system-card.system-card-active:hover {
background: var(--ink) !important;
}
/* ---------- Tabs ---------- */
.gradio-container .tab-nav {
border-bottom: 1px solid var(--rule) !important;
background: transparent !important;
}
.gradio-container .tab-nav button {
background: transparent !important;
color: var(--ink-soft) !important;
border-radius: 0 !important;
border: 0 !important;
border-bottom: 3px solid transparent !important;
text-transform: uppercase !important;
letter-spacing: 1.5px !important;
font-size: 0.78rem !important;
font-weight: 600 !important;
padding: 10px 14px !important;
}
.gradio-container .tab-nav button.selected,
.gradio-container .tab-nav button[aria-selected="true"] {
color: var(--ink) !important;
border-bottom-color: var(--accent) !important;
}
/* ---------- Scenario cards (gr.Button with .scenario-card) ---------- */
.scenario-card {
background: var(--surface-2) !important;
color: var(--ink) !important;
border: 1px solid var(--rule) !important;
border-radius: 0 !important;
padding: 12px 14px 30px 14px !important;
margin: 0 0 8px 0 !important;
cursor: pointer !important;
/* Gradio button defaults to flex-center; force left alignment so the
title and one-liner share a flush-left edge. */
display: flex !important;
flex-direction: column !important;
align-items: flex-start !important;
justify-content: flex-start !important;
text-align: left !important;
box-shadow: 3px 3px 0 var(--rule) !important;
transition: transform 0.06s ease, box-shadow 0.06s ease, background 0.12s ease !important;
white-space: pre-line !important;
line-height: 1.35 !important;
font-weight: 500 !important;
min-height: unset !important;
position: relative !important;
}
.scenario-card:hover {
background: var(--surface-3) !important;
}
.scenario-card:active {
transform: translate(2px, 2px) !important;
box-shadow: 1px 1px 0 var(--rule) !important;
}
/* "Play now" affordance in the bottom-right corner of each scenario card.
Subtle by default, brightens on hover — telegraphs that the card is
actionable without dominating the title + oneliner above. The card's
padding-bottom is bumped to 30px to leave room. */
.scenario-card::after {
content: "▶ Play now";
position: absolute;
bottom: 7px;
right: 10px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: var(--accent);
background: var(--surface);
border: 1px solid var(--rule);
padding: 2px 7px;
box-shadow: 1.5px 1.5px 0 var(--rule);
opacity: 0.75;
transition: opacity 0.15s ease, transform 0.06s ease, box-shadow 0.06s ease;
}
.scenario-card:hover::after {
opacity: 1;
}
.scenario-card:active::after {
transform: translate(1px, 1px);
box-shadow: 0.5px 0.5px 0 var(--rule);
}
/* "Click here" cue on the first scenario card. One-shot pulse animation
that runs 5 cycles (~7.5s total) on landing so a new visitor sees a
clear visual hint without needing any auto-play replay machinery.
The .has-clicked class on body (set by the click delegate further down)
kills the animation the moment the user interacts. */
@keyframes scenario-hint-pulse {
0%, 100% {
box-shadow: 1px 1px 0 var(--rule), 0 0 0 0 var(--accent-soft, rgba(80, 120, 255, 0.45));
}
50% {
box-shadow: 1px 1px 0 var(--rule), 0 0 0 8px transparent;
}
}
.scenario-card:first-of-type {
animation: scenario-hint-pulse 1.5s ease-in-out 5;
}
body.has-clicked .scenario-card:first-of-type {
animation: none;
}
/* ---------- Primary button (Plan trip) — geometry only; theme.set() owns
colors, hover, border. We just stamp the shadow + uppercase styling. */
.gradio-container button.primary,
.gradio-container button[variant="primary"] {
box-shadow: 3px 3px 0 var(--accent) !important;
text-transform: uppercase !important;
letter-spacing: 1.5px !important;
font-weight: 600 !important;
padding: 10px 18px !important;
transition: transform 0.06s ease, box-shadow 0.06s ease !important;
}
.gradio-container button.primary:hover,
.gradio-container button[variant="primary"]:hover {
transform: translate(1px, 1px) !important;
box-shadow: 2px 2px 0 var(--accent) !important;
}
.gradio-container button.primary:active,
.gradio-container button[variant="primary"]:active {
transform: translate(3px, 3px) !important;
box-shadow: 0 0 0 var(--accent) !important;
}
/* ---------- Form controls — sizing + restore border (the flatten rule
above zeroed every .block/.wrap border). theme.set() picks the colour
via --input-border-color but our flatten kills the actual border-width.
Re-add it here only for wraps that hold an input or textarea. */
.gradio-container select,
.gradio-container input[type="text"],
.gradio-container textarea,
.gradio-container .wrap input {
padding: 10px 14px !important;
font-size: 0.95rem !important;
line-height: 1.4 !important;
}
#kiosk-app .wrap:has(input[type="text"]),
#kiosk-app .wrap:has(input[role="combobox"]),
#kiosk-app .wrap:has(textarea),
#kiosk-app .secondary-wrap:has(input) {
border: 1px solid var(--rule) !important;
}
/* Dropdown options popup keeps visible padding even after we flatten .block. */
.gradio-container [data-testid="dropdown"] ul li {
padding: 8px 14px !important;
font-size: 0.95rem !important;
}
.gradio-container label,
.gradio-container .label-wrap span,
.gradio-container span[data-testid="block-label"] {
font-weight: 600 !important;
text-transform: uppercase !important;
letter-spacing: 1.2px !important;
font-size: 0.72rem !important;
}
/* ---------- Optional events checkbox group ----------
Plain vertical list: one row per option, native/Gradio-styled checkbox
glyph + label text. No per-chip background or border on the label
wrapper. The cascade fix forces text-transform/letter-spacing back to
normal on the label (and descendants — Gradio wraps text in a span
that doesn't otherwise pick up the override). Each label is forced
to width:100% so even if Gradio's wrapper is flex-row, the labels
take a full line each (vertical stack). */
.gradio-container [data-testid="checkbox-group"] .wrap,
.gradio-container [data-testid="checkboxgroup"] .wrap,
.gradio-container .checkbox-group,
.gradio-container .gradio-checkboxgroup .wrap,
.gradio-container .gradio-checkboxgroup {
display: flex !important;
flex-direction: column !important;
align-items: stretch !important;
gap: 2px !important;
padding: 4px 0 !important;
width: 100% !important;
}
.gradio-container [data-testid="checkbox-group"] label,
.gradio-container [data-testid="checkboxgroup"] label,
.gradio-container .checkbox-group label,
.gradio-container .gradio-checkboxgroup label,
.gradio-container label:has(input[type="checkbox"]) {
display: flex !important;
width: 100% !important;
flex: 0 0 auto !important;
align-items: flex-start !important;
gap: 8px !important;
background: transparent !important;
border: 0 !important;
padding: 4px 0 !important;
margin: 0 !important;
font-family: inherit !important;
font-size: 0.88rem !important;
font-weight: 500 !important;
text-transform: none !important;
letter-spacing: 0 !important;
color: var(--ink) !important;
cursor: pointer !important;
box-shadow: none !important;
line-height: 1.4 !important;
white-space: normal !important;
overflow-wrap: anywhere !important;
}
/* Force the inner text span to drop the inherited uppercase + tracking. */
.gradio-container [data-testid="checkbox-group"] label *,
.gradio-container [data-testid="checkboxgroup"] label *,
.gradio-container .checkbox-group label *,
.gradio-container .gradio-checkboxgroup label * {
text-transform: none !important;
letter-spacing: 0 !important;
font-size: inherit !important;
font-weight: inherit !important;
color: inherit !important;
}
/* Checked state — emphasize via weight + accent colour, no box fill on the row. */
.gradio-container [data-testid="checkbox-group"] label:has(input:checked),
.gradio-container [data-testid="checkboxgroup"] label:has(input:checked),
.gradio-container .checkbox-group label:has(input:checked),
.gradio-container .gradio-checkboxgroup label:has(input:checked),
.gradio-container label:has(input[type="checkbox"]:checked) {
color: var(--accent) !important;
font-weight: 700 !important;
}
/* ---------- Chat thread — flat stream layout ----------
No outer border, no per-bubble shadow. User vs bot distinction by fill
only. <details> blocks (reasoning, tool calls) are flat — open/close
state alone communicates structure. Code blocks keep a subtle fill so
JSON stands out from prose. */
#chat-thread {
border: 0 !important;
box-shadow: none !important;
padding: 0 !important;
}
/* Empty-state hint inside Gradio's chatbot placeholder (shown until the
first message arrives — Gradio replaces .placeholder-content with the
messages list once any content is present, so this disappears
automatically on rollout start). */
#chat-thread .placeholder-content {
min-height: 200px;
display: flex !important;
align-items: center;
justify-content: center;
}
#chat-thread .placeholder-content::before {
content: "Pick a scenario from the Scenarios tab — or build one in the Custom tab. The model's reasoning, tool calls, and final state will stream here.";
display: block;
padding: 24px;
color: var(--ink-faint);
font-size: 0.92rem;
font-style: italic;
text-align: center;
line-height: 1.55;
max-width: 480px;
}
/* Hide chatbot's clear-chat trashcan (Gradio chrome we don't need). */
#chat-thread button[aria-label*="lear" i],
#chat-thread button[title*="lear" i],
#chat-thread .clear-button,
#chat-thread [class*="clear-btn"] {
display: none !important;
}
/* User bubble — fill only. Tight outer padding, then clear all inner
wrappers (Gradio nests message-row > message > bubble). */
#chat-thread [data-testid="user"],
#chat-thread .message.user {
background: var(--surface-2) !important;
border: 0 !important;
box-shadow: none !important;
padding: 8px 14px !important;
margin: 0 0 14px 0 !important;
line-height: 1.55 !important;
}
/* Bot turn — transparent. */
#chat-thread [data-testid="bot"],
#chat-thread .message.bot {
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
padding: 4px 14px !important;
margin: 0 0 8px 0 !important;
line-height: 1.55 !important;
}
/* Strip every inner wrapper inside a message — bubbles, metadata-blocks,
message-content shells. Outer padding alone defines breathing room. */
#chat-thread [data-testid="user"] *,
#chat-thread [data-testid="bot"] *,
#chat-thread .message *,
#chat-thread [class*="bubble"],
#chat-thread [class*="message-bubble"],
#chat-thread [class*="message-content"] {
border: 0 !important;
box-shadow: none !important;
}
#chat-thread [class*="bubble"],
#chat-thread [class*="message-bubble"],
#chat-thread [class*="message-content"],
/* Gradio nests .message.panel-full-width as an ANCESTOR of [data-testid],
not a descendant — that's where the "weird 6px" was coming from. Plus
.message-wrap adds 16px between turns. Zero both. */
#chat-thread .message.panel-full-width,
#chat-thread .panel-full-width,
#chat-thread .message-wrap {
padding: 0 !important;
margin: 0 !important;
background: transparent !important;
}
#chat-thread .avatar-container,
#chat-thread [class*="avatar"] {
display: none !important;
}
/* Animated "thinking" placeholder. Pulses opacity so the user sees the
model is working rather than wondering if the page hung. */
#chat-thread .loading-dots {
display: inline-block;
color: var(--ink-faint);
animation: kiosk-loading-pulse 1.2s ease-in-out infinite;
}
/* Per-round stats line appended after each generation round —
elapsed / tokens / tok/s. Small, mono, dim. Lets us eyeball decode
throughput across deploys (e.g. before vs after flash-attn) without
any extra UI surface. */
#chat-thread .round-stats {
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
font-size: 0.7rem;
color: var(--ink-faint);
text-transform: uppercase;
letter-spacing: 1px;
margin-top: 6px;
padding-top: 4px;
border-top: 1px dashed var(--rule);
}
@keyframes kiosk-loading-pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
/* Reasoning + tool metadata <details> — flat, no borders. */
#chat-thread details {
border: 0 !important;
background: transparent !important;
margin: 0 0 4px 0 !important;
}
#chat-thread details summary {
font-family: 'IBM Plex Mono', ui-monospace, monospace !important;
font-size: 0.78rem !important;
text-transform: uppercase !important;
letter-spacing: 1px !important;
padding: 6px 0 !important;
color: var(--ink-soft) !important;
border: 0 !important;
}
#chat-thread details[open] {
background: transparent !important;
}
/* Code blocks inside reasoning — keep a subtle fill so JSON tool args
stand out from prose. No border. */
#chat-thread pre,
#chat-thread code {
background: var(--code-bg) !important;
border: 0 !important;
font-size: 0.78rem !important;
padding: 10px 12px !important;
}
/* ---------- Tool call card ----------
Inlined as raw HTML in the assistant bubble (chatbot has sanitize_html
disabled). Status drives the left rule colour. Power users can crack
open the <details> for the raw JSON. */
#chat-thread .metro-tool-card {
background: var(--surface-2) !important;
border: 1px solid var(--rule) !important;
border-left: 3px solid var(--positive) !important;
padding: 10px 12px !important;
margin: 8px 0 !important;
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace !important;
font-size: 0.82rem !important;
line-height: 1.5 !important;
}
#chat-thread .metro-tool-rejected { border-left-color: var(--warning) !important; }
#chat-thread .metro-tool-hallucinated { border-left-color: var(--accent) !important; }
#chat-thread .metro-tool-head {
display: flex !important;
gap: 8px !important;
align-items: baseline !important;
margin-bottom: 6px !important;
}
#chat-thread .metro-tool-glyph {
color: var(--positive) !important;
font-weight: 600 !important;
}
#chat-thread .metro-tool-rejected .metro-tool-glyph { color: var(--warning) !important; }
#chat-thread .metro-tool-hallucinated .metro-tool-glyph { color: var(--accent) !important; }
#chat-thread .metro-tool-name {
font-weight: 600 !important;
color: var(--ink) !important;
}
#chat-thread .metro-tool-time {
margin-left: auto !important;
color: var(--ink-faint) !important;
font-size: 0.72rem !important;
letter-spacing: 0.5px !important;
}
#chat-thread .metro-tool-row {
display: grid !important;
grid-template-columns: 36px 1fr !important;
gap: 8px !important;
align-items: baseline !important;
}
#chat-thread .metro-tool-key {
color: var(--ink-faint) !important;
text-transform: uppercase !important;
font-size: 0.7rem !important;
letter-spacing: 1.2px !important;
}
#chat-thread .metro-tool-val {
color: var(--ink) !important;
word-break: break-word !important;
}
#chat-thread .metro-tool-raw {
margin-top: 8px !important;
border-top: 1px solid var(--rule) !important;
padding-top: 4px !important;
}
#chat-thread .metro-tool-raw summary {
color: var(--ink-faint) !important;
font-size: 0.68rem !important;
text-transform: uppercase !important;
letter-spacing: 1.2px !important;
cursor: pointer !important;
padding: 4px 0 !important;
list-style: none !important;
}
#chat-thread .metro-tool-raw summary::-webkit-details-marker { display: none !important; }
#chat-thread .metro-tool-raw summary::before {
content: "▸ ";
display: inline-block;
transition: transform 0.1s ease;
}
#chat-thread .metro-tool-raw[open] summary::before { content: "▾ "; }
#chat-thread .metro-tool-raw-label {
color: var(--ink-faint) !important;
text-transform: uppercase !important;
font-size: 0.65rem !important;
letter-spacing: 1.2px !important;
margin: 6px 0 2px 0 !important;
}
#chat-thread .metro-tool-raw pre {
margin: 0 0 4px 0 !important;
font-size: 0.72rem !important;
line-height: 1.4 !important;
white-space: pre-wrap !important;
word-break: break-word !important;
}
/* ---------- Kiosk display (under the map) ----------
Fare card + advisory cards + action card. Populated by RENDER_BANNERS_JS
off kiosk_state. Hidden until populated. */
#kiosk-display-wrap { width: 100%; }
#kiosk-display {
display: flex;
flex-direction: column;
gap: 8px;
}
.kiosk-disclaimer {
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--ink-faint);
text-align: center;
margin-top: 8px;
padding: 4px 8px;
}
#kiosk-display:not(.has-banners) { display: none; }
#kiosk-display.has-banners {
margin-top: 0;
border-top: 1px solid var(--rule);
padding-top: 12px;
}
.kiosk-card {
background: var(--surface-2);
border: 1px solid var(--rule);
border-left: 3px solid var(--rule);
padding: 10px 12px;
font-size: 0.85rem;
line-height: 1.45;
}
.kiosk-card-key {
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 1.4px;
color: var(--ink-faint);
margin-bottom: 4px;
}
/* Kiosk message — passenger-facing prose, larger sans-serif, no
uppercase key. Sits at the top of the kiosk-display block. */
.kiosk-message {
border-left-color: var(--ink-soft) !important;
padding: 14px 16px !important;
}
.kiosk-message-body {
font-family: 'IBM Plex Sans', system-ui, sans-serif !important;
font-size: 1rem;
font-weight: 400;
line-height: 1.55;
color: var(--ink);
white-space: pre-wrap;
}
/* Fare */
.kiosk-fare { border-left-color: var(--ink); }
.kiosk-fare-total {
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
font-size: 1.15rem;
font-weight: 600;
color: var(--ink);
letter-spacing: -0.01em;
}
.kiosk-fare-pax {
font-size: 0.78rem;
color: var(--ink-soft);
margin-top: 2px;
}
/* Advisories — severity-coded left rule */
.kiosk-sev-info { border-left-color: var(--ink-faint); }
.kiosk-sev-warning { border-left-color: var(--warning); }
.kiosk-sev-critical { border-left-color: var(--accent); }
.kiosk-advisory-title {
font-weight: 600;
color: var(--ink);
}
.kiosk-advisory-body {
color: var(--ink-soft);
margin-top: 2px;
font-size: 0.82rem;
}
/* Action — full-width pill, status-coded fill */
.kiosk-action {
border-left: 1px solid var(--rule);
padding: 12px 14px;
text-align: center;
}
.kiosk-action-label {
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
}
.kiosk-action-purchase {
background: var(--positive);
border-color: var(--positive);
}
.kiosk-action-purchase .kiosk-action-label { color: #fff; }
.kiosk-action-block {
background: var(--accent);
border-color: var(--accent);
}
.kiosk-action-block .kiosk-action-label { color: #fff; }
.kiosk-action-staff {
background: var(--warning);
border-color: var(--warning);
}
.kiosk-action-staff .kiosk-action-label { color: var(--ink); }
/* Hidden state textboxes used as JS↔Python transport for map clicks. */
.map-state-hidden { display: none !important; }
#map-origin-id, #map-dest-id { display: none !important; }
/* ---------- Leaflet controls — dark-mode override ----------
Leaflet ships its own CSS that hardcodes white backgrounds and dark text.
In dark mode the +/- zoom buttons render white-on-white. Override them. */
body.dark .leaflet-bar a,
body.dark .leaflet-control-zoom a {
background: var(--surface-2) !important;
color: var(--ink) !important;
border-color: var(--rule) !important;
}
body.dark .leaflet-bar a:hover,
body.dark .leaflet-control-zoom a:hover {
background: var(--surface-3) !important;
}
body.dark .leaflet-control-attribution {
background: rgba(20, 23, 26, 0.85) !important;
color: var(--ink-soft) !important;
}
body.dark .leaflet-control-attribution a {
color: var(--ink) !important;
}
/* Tooltip bubbles on station markers (hover names + origin/dest pick labels). */
body.dark .leaflet-tooltip {
background: var(--surface-2) !important;
color: var(--ink) !important;
border: 1px solid var(--rule) !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.6) !important;
}
body.dark .leaflet-tooltip-right::before {
border-right-color: var(--surface-2) !important;
}
body.dark .leaflet-tooltip-left::before {
border-left-color: var(--surface-2) !important;
}
body.dark .leaflet-tooltip-top::before {
border-top-color: var(--surface-2) !important;
}
body.dark .leaflet-tooltip-bottom::before {
border-bottom-color: var(--surface-2) !important;
}
/* ---------- Map ---------- */
/* CTA row above the map: hint text expands, Plan-trip button stays compact
on the right. The button is disabled (dimmed, pointer-events: none) until
JS adds .map-plan-ready when both map-pick textboxes are populated. */
#map-cta-row {
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
gap: 0 !important;
margin: 0 !important;
}
#map-cta-row > #map-status-wrap { flex: 1 1 auto !important; min-width: 0 !important; }
#map-cta-row > #map-plan-btn,
#map-cta-row > .block:has(#map-plan-btn) { flex: 0 0 auto !important; }
#map-status {
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace !important;
font-size: 0.78rem !important;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--ink-soft);
padding: 8px 12px;
background: var(--surface-2);
border: 1px solid var(--rule);
border-right: 0;
border-bottom: 0;
margin: 0 !important;
height: 100%;
display: flex;
align-items: center;
box-sizing: border-box;
/* Reserve enough vertical space for the longest expected wrap (3 lines
on ≤360px) so the status bar height stays constant across state
changes — no map jitter when origin/dest text shortens or grows. */
min-height: 2.8em;
}
/* Plan-trip button styled as a flush extension of the status bar */
#map-plan-btn {
border-radius: 0 !important;
box-shadow: none !important;
border: 1px solid var(--rule) !important;
border-bottom: 0 !important;
padding: 8px 16px !important;
font-size: 0.78rem !important;
height: auto !important;
min-width: 0 !important;
transition: opacity 0.2s ease !important;
}
.map-plan-disabled #map-plan-btn,
#map-plan-btn.map-plan-disabled,
.map-plan-disabled.map-plan-disabled {
opacity: 0.35 !important;
pointer-events: none !important;
cursor: not-allowed !important;
filter: grayscale(0.5);
}
#metro-map-wrap {
width: 100%;
border: 1px solid var(--rule);
border-top: 4px double var(--rule);
}
#metro-map {
width: 100%;
height: 600px;
background: var(--surface-2);
cursor: pointer;
}
/* ---------- Two-column body (≥641px) ----------
Left column: controls (tabs). Right column: map + chat.
Gradio wraps each gr.Column in a .block we've already flattened, so the
flex math operates on the columns directly. */
#kiosk-body {
display: flex !important;
flex-direction: row !important;
gap: 36px !important;
align-items: flex-start !important;
flex-wrap: nowrap !important;
margin-top: 8px !important;
}
#kiosk-controls {
flex: 0 0 calc(45% - 18px) !important;
min-width: 0 !important;
}
#kiosk-output {
flex: 1 1 calc(55% - 18px) !important;
min-width: 0 !important;
display: flex !important;
flex-direction: column !important;
gap: 18px !important;
}
/* Default Gradio footer hide (we draw our own #site-footer) */
.gradio-container footer { display: none !important; }
/* ---------- Site footer (closure for the page) ---------- */
#site-footer {
padding: 22px 0 10px 0;
margin-top: 26px;
border-top: 1px solid var(--rule);
display: flex;
flex-direction: column;
gap: 6px;
}
.footer-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
align-items: baseline;
}
.footer-link {
color: var(--ink-soft) !important;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 1.4px;
font-weight: 500;
}
.footer-link:hover { color: var(--accent) !important; }
.footer-link[data-placeholder="true"]::after {
content: " (soon)";
color: var(--ink-faint);
font-size: 0.65rem;
letter-spacing: 1px;
}
.footer-link[data-placeholder="true"] {
color: var(--ink-faint) !important;
cursor: default;
text-decoration-style: dashed;
}
.footer-attribution {
color: var(--ink-soft);
font-size: 0.72rem;
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
}
.footer-attribution-link {
text-transform: none !important;
letter-spacing: 0 !important;
font-size: 0.72rem !important;
}
.footer-credit {
color: var(--ink-faint);
font-size: 0.7rem;
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
letter-spacing: 0.6px;
}
/* ---------- Rollout lock: while body.kiosk-running, no rollout-triggering
button can be clicked. The streaming chat already serves as the visible
"running" indicator; this just prevents a second click from queueing a
parallel rollout (would clobber chat / kiosk_state). ---------- */
body.kiosk-running .scenario-card,
body.kiosk-running .system-card,
body.kiosk-running #kiosk-controls button[variant="primary"],
body.kiosk-running #kiosk-controls .primary {
pointer-events: none !important;
opacity: 0.55 !important;
cursor: wait !important;
filter: grayscale(0.4);
transition: opacity 0.2s ease;
}
/* ---------- Mobile / narrow tablet (≤800px) — collapse to single column ---------- */
@media (max-width: 800px) {
.gradio-container { padding: 10px 8px !important; }
/* Gradio's inner .main.fillable also has 32px horizontal padding by
default — combined with the container padding above we'd lose
~80-128px on a 375px screen. Force both flush on mobile. */
.gradio-container .main.fillable {
padding: 0 !important;
}
/* 4:5 portrait map on mobile — at 360px content width that's ~450px
tall, enough to fit a metro network legibly without panning. */
#metro-map {
height: auto;
aspect-ratio: 4 / 5;
}
/* Tighter chat messages on mobile. Bot keeps a small gap; user
collapses flush so the fill-coloured user bubble sits tight
against the bot reply. Smaller font + tighter line-height than
desktop — the column is narrow and the inherited 1.55 line-height
is way too much air. */
#chat-thread [data-testid="bot"],
#chat-thread .message.bot {
padding: 4px 6px !important;
margin: 0 0 6px 0 !important;
font-size: 0.78rem !important;
line-height: 1.35 !important;
}
#chat-thread [data-testid="user"],
#chat-thread .message.user {
padding: 4px 6px !important;
margin: 0 !important;
font-size: 0.78rem !important;
line-height: 1.35 !important;
}
/* Compact scenario cards on mobile: tighter padding, smaller gap,
smaller oneliner. Keep the 3×3 box-shadow — it gives the cards
their tactile "play" and visually rhymes with the system selector
above. ::first-line styles the title slightly larger than the body
so the oneliner shrinks without dragging the title with it. The
28px bottom padding reserves room for the ▶ Play now pill so it
never overlaps the oneliner text on narrow viewports. */
.scenario-card {
padding: 7px 10px 28px 10px !important;
margin: 0 0 4px 0 !important;
font-size: 0.78rem !important;
line-height: 1.3 !important;
}
.scenario-card::first-line {
font-size: 0.9rem;
font-weight: 600;
}
.scenario-card::after {
bottom: 6px;
right: 8px;
}
#kiosk-body {
flex-direction: column !important;
gap: 14px !important;
}
#kiosk-controls,
#kiosk-output {
flex: 1 1 100% !important;
width: 100% !important;
}
/* Tektur is wider than IBM Plex — drop the title size so
"MetroLLM-Bench" doesn't bump the right edge on a 360px screen. */
.site-title { font-size: 1.5rem; }
.site-subtitle { font-size: 0.88rem; }
/* Empty-state hint inside #kiosk-display before any rollout. The
global rule above hides #kiosk-display when .has-banners is
absent (so it doesn't take vertical space on desktop). On
mobile we want the opposite: a visible placeholder so the
space between map and footer isn't void. RENDER_BANNERS_JS
adds .has-banners after submit_assistant_state, which makes
the :not(.has-banners) selector stop matching and the hint
gives way to the real cards. */
#kiosk-display:not(.has-banners) {
display: block !important;
}
#kiosk-display:not(.has-banners)::before {
content: 'Fare, advisory and action will appear here once the model submits.';
display: block;
margin-top: 10px;
padding: 14px 14px;
text-align: center;
color: var(--ink-faint);
font-size: 0.82rem;
font-style: italic;
background: var(--surface-2);
border: 1px dashed var(--rule);
border-radius: 4px;
}
}
@media (max-width: 420px) {
/* Very narrow phones — give the title another notch of headroom. */
.site-title { font-size: 1.3rem; }
}
"""
# Per-system colours injected at the end so they win over the generic
# .system-card-active rule. Each card lights up in its own line colour
# when active.
CUSTOM_CSS += "\n".join(
f"#system-card-{slug}.system-card-active {{ "
f"background: {info['bg']} !important; "
f"border-color: {info['bg']} !important; "
f"color: {info['fg']} !important; }}"
for slug, info in SYSTEM_INFO.items()
)
# --- UI builders -----------------------------------------------------------
def _scenario_button_label(sc: dict) -> str:
"""Build the multiline label that becomes the button text."""
return f"{sc['title']}\n{sc.get('oneliner', '')}"
def _line_choices(bundle: dict) -> list[tuple[str, str]]:
"""[(line_name, line_id), ...] for the line picker."""
return [(l["name"], l.get("id") or l["name"].lower()) for l in bundle["lines"]]
def _stations_on_line(bundle: dict, line_id: str) -> list[tuple[str, str]]:
"""[(station_name, station_id), ...] alphabetically, restricted to the
given line. Returns [] if line_id is None/unknown."""
if not line_id:
return []
return sorted(
[
(s["name"], s["id"])
for s in bundle["stations"]
if line_id in (s.get("lines") or [])
],
key=lambda x: x[0],
)
def _event_chips(bundle: dict) -> list[tuple[str, str]]:
"""[(human label, event_id), ...] for the optional-events checkbox group.
Labels are unique per event (no synthetic collisions like two distinct
'Red planned maintenance' events sharing a label) and not truncated
mid-word — CSS handles any wrapping. Distinguishers come from the data:
segment endpoints (resolved to names), schedule, or hurricane phase."""
sb = bundle["station_by_id"]
chips: list[tuple[str, str]] = []
for ev in bundle["events"]:
d = ev.get("disruption") or {}
ev_type_raw = d.get("type", "") or ""
sn = ev.get("station_name")
if sn:
label = f"{sn} closed" if ev_type_raw == "station_closure" else sn
elif ev_type_raw == "hurricane_warning":
# Hurricane events vary by phase (advisory / landfall_warning /
# reduced_service / full_suspension) — that's the unique key.
phase = (ev.get("phase") or "").replace("_", " ")
cat = ev.get("category") or ""
if phase:
label = f"Hurricane: {phase}"
elif cat:
label = f"Hurricane ({cat})"
else:
label = "Hurricane warning"
else:
line = ev.get("line") or d.get("line") or ""
ev_type = ev_type_raw.replace("_", " ")
head = f"{line.title()} {ev_type}".strip() if line else ev_type
extras = []
seg = ev.get("segment") or d.get("segment") or {}
if isinstance(seg, dict):
f_name = (sb.get(seg.get("from_station")) or {}).get("name")
t_name = (sb.get(seg.get("to_station")) or {}).get("name")
if f_name and t_name:
extras.append(f"{f_name}{t_name}")
sched = ev.get("schedule") or d.get("schedule")
if sched:
extras.append(sched.replace("_", " "))
if extras:
label = f"{head}: {', '.join(extras)}"
else:
label = head or (d.get("message") or "")
chips.append((label or ev.get("id", "?"), ev.get("id", "")))
return chips
# _strip_think_block, _tool_status, _summarise_result, _route_with_coords
# extracted to render.py (imported above).
# _summarise_args, _format_tool_card extracted to render.py (imported above).
def _run_agent(system: str, origin_id: str, dest_id: str, event_ids: list[str]):
"""Multi-round agent loop. Generator: yields
(history, route_for_map, kiosk_payload) after each round.
- history drives the chatbot (Output tab on left)
- route_for_map drives the polyline redraw via DRAW_ROUTE_JS
- kiosk_payload drives the fare/advisory/action banners under the map
via RENDER_BANNERS_JS. Stays None until submit_assistant_state accepts
so banners only appear after the run resolves."""
history: list = []
current_route: list | None = None
current_kiosk: dict | None = None
bundle = SYSTEMS.get(system) or SYSTEMS[DEFAULT_SYSTEM]
system = bundle["slug"]
if not origin_id or not dest_id:
history.append({"role": "assistant", "content": "Pick an origin and a destination first."})
yield history, current_route, current_kiosk
return
origin = prompts.validate_station(system, origin_id)
dest = prompts.validate_station(system, dest_id)
events = [prompts.validate_event(system, e) for e in event_ids if e]
session_id = f"sess-{uuid.uuid4().hex[:12]}"
tools.set_disruptions(
session_id=session_id,
system=system,
disruptions=prompts.event_for_disruption_payload(events),
)
system_context = prompts.build_system_context(events)
system_prompt = mdl.build_system_prompt(system, system_context)
user_prompt = prompts.build_user_prompt(origin["name"], dest["name"], events)
display_prompt = prompts.build_user_prompt_display(origin["name"], dest["name"], events)
history.append({"role": "user", "content": display_prompt})
yield history, current_route, current_kiosk
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
for round_n in range(MAX_ROUNDS):
t0 = time.time()
# Live reasoning placeholder — content fills in as tokens arrive.
# No metadata wrapper: reasoning renders as plain assistant prose so
# it stays expanded (the user reads it as the model thinks).
reasoning_idx = len(history)
history.append({"role": "assistant", "content": _LOADING_DOTS})
yield history, current_route, current_kiosk
raw_output = ""
# Throttle UI yields so we don't flush per single-token chunk on
# streamers that emit at >50/s — yield at most every 0.15s.
last_yield = time.time()
for _chunk, accum in _stream_one_round(messages):
raw_output = accum
narrative = re.sub(r"<tool_call>.*?</tool_call>\s*", "",
raw_output, flags=re.DOTALL)
narrative = _strip_think_block(narrative)
history[reasoning_idx]["content"] = narrative or _LOADING_DOTS
now = time.time()
if now - last_yield >= 0.15:
yield history, current_route, current_kiosk
last_yield = now
elapsed = time.time() - t0
# Per-round timing/throughput line — appended to the chat so we can
# eyeball decode performance (e.g. before/after flash-attn) without
# any extra UI. Uses the model's own tokenizer for accuracy.
try:
tok_count = len(mdl.tokenizer.encode(raw_output, add_special_tokens=False))
except Exception:
tok_count = max(1, len(raw_output) // 4)
tok_rate = tok_count / elapsed if elapsed > 0 else 0.0
stats_line = (
f'<div class="round-stats">⏱ round {round_n + 1}: '
f'{elapsed:.1f}s · {tok_count} tok · {tok_rate:.1f} tok/s</div>'
)
# Finalize the reasoning block — content becomes the final narrative.
narrative = re.sub(r"<tool_call>.*?</tool_call>\s*", "",
raw_output, flags=re.DOTALL)
narrative = _strip_think_block(narrative)
if narrative:
history[reasoning_idx] = {
"role": "assistant",
"content": narrative + "\n\n" + stats_line,
}
else:
# Model went straight to a tool call with no prose — keep the
# stats line in place of the placeholder so the timing is still
# visible for that round.
history[reasoning_idx] = {"role": "assistant", "content": stats_line}
yield history, current_route, current_kiosk
calls = mdl.parse_tool_calls(raw_output)
# No tool calls → model emitted plain text. Surface and finish.
if not calls:
cleaned = _strip_think_block(raw_output)
history.append({"role": "assistant", "content": cleaned or "(no response)"})
yield history, current_route, current_kiosk
return
# Append the assistant turn (with the raw tool_call XML) so the next
# round's chat template sees what the model just wrote.
messages.append({"role": "assistant", "content": raw_output})
for call in calls:
t_tool = time.time()
result = tools.dispatch(
call["name"], call["arguments"], session_id, system_context
)
tool_elapsed = time.time() - t_tool
messages.append({"role": "tool", "content": json.dumps(result)})
status, glyph = _tool_status(call["name"], result)
card_html = _format_tool_card(
name=call["name"],
args=call["arguments"],
result=result,
elapsed_ms=tool_elapsed * 1000,
status=status,
glyph=glyph,
bundle=bundle,
)
history.append({"role": "assistant", "content": card_html})
yield history, current_route, current_kiosk
if call["name"] == "submit_assistant_state":
accepted = isinstance(result, dict) and result.get("accepted") is True
if not accepted:
continue
args = call["arguments"]
kiosk_msg = (
args.get("assistant_message")
or mdl.extract_assistant_message(raw_output)
or "Done."
)
# Build the lat/lon-annotated route for the map redraw.
route_obj = args.get("route") or {}
stops = route_obj.get("stops") if isinstance(route_obj, dict) else []
current_route = _route_with_coords(stops or [], bundle["station_by_id"])
# Build the kiosk-display payload (prose message + fare +
# advisories + action). RENDER_BANNERS_JS reads this off
# kiosk_state and writes the banners into #kiosk-display
# under the map. The message card lands first as the
# passenger-facing prose headline; the structured cards
# follow as supporting detail.
current_kiosk = {
"outcome": args.get("outcome"),
"message": kiosk_msg,
"fare": args.get("fare_quote"),
"advisories": args.get("advisory_banners") or [],
"action": args.get("kiosk_action"),
}
history.append({"role": "assistant", "content": kiosk_msg})
yield history, current_route, current_kiosk
return
history.append({
"role": "assistant",
"content": "Turn budget exhausted — please refer to staff.",
})
yield history, current_route, current_kiosk
# --- Build Blocks ----------------------------------------------------------
def _switch_system_js(slug: str) -> str:
"""Build the click-side JS for a specific system-card button. Slug is
baked into the function body — using `inputs=gr.State(slug)` was passing
None to the JS hook for reasons I haven't fully traced; closure-binding
via a Python f-string is the workaround. The returned function does
side effects only (no return value) so the Python `fn` is what populates
the outputs."""
return f"""
() => {{
var slug = {json.dumps(slug)};
if (!window.METRO_GEOS || !window.METRO_GEOS[slug]) return;
// Toggle active highlight on the system-card row.
document.querySelectorAll('.system-card').forEach(b => b.classList.remove('system-card-active'));
var activeBtn = document.getElementById('system-card-' + slug);
if (activeBtn) activeBtn.classList.add('system-card-active');
window._activeSystem = slug;
window.METRO_GEO = window.METRO_GEOS[slug];
var el = document.getElementById('metro-map');
if (el && window._metroMap) {{
window._metroMap.remove();
delete window._metroMap;
el._leafletReady = false;
el.innerHTML = '';
}}
// Reset map-pick state so the new system starts clean.
window._originPickMarker = null;
window._destPickMarker = null;
window._mapClickMode = 'origin';
var st = document.getElementById('map-status');
if (st) st.textContent = 'Pick two stations on the map — or use Custom tab.';
if (typeof window._initMetroMap === 'function') window._initMetroMap();
}}
"""
with gr.Blocks(title="MetroLLM-Bench Demo") as demo:
with gr.Column(elem_id="kiosk-app"):
gr.HTML(SITE_HEADER_HTML, elem_id="site-header-wrap")
# System selector: one card-button per system, two-line label
# (system name + city/country). Active highlight is toggled by
# SWITCH_SYSTEM_JS via the elem_id `system-card-{slug}`.
with gr.Row(elem_id="kiosk-header"):
system_btns: dict[str, gr.Button] = {}
for _slug in KNOWN_SYSTEMS:
system_btns[_slug] = gr.Button(
_system_card_label(_slug),
elem_id=f"system-card-{_slug}",
elem_classes=["system-card"],
scale=1,
)
current_system = gr.State(DEFAULT_SYSTEM)
# Two-column body. CSS @media collapses to single column under 640px.
with gr.Row(elem_id="kiosk-body"):
# Left: tabs (Scenarios | Custom | Output). Tabs container is stable
# so chat survives system switches; only the Scenarios and Custom
# contents re-render via @gr.render when current_system changes.
with gr.Column(elem_id="kiosk-controls"):
with gr.Tabs(selected="scenarios") as tabs_left:
with gr.Tab("Scenarios", id="scenarios"):
@gr.render(inputs=current_system)
def _render_scenarios(system):
bundle = SYSTEMS.get(system) or SYSTEMS[DEFAULT_SYSTEM]
for sc in bundle["scenarios"]:
btn = gr.Button(
_scenario_button_label(sc),
elem_classes=["scenario-card"],
)
# Lock UI → switch to Output tab → run agent →
# Collapse LOCK + tab-flip into one click step
# (Gradio doesn't reliably chain .then() after
# a JS-only fn=None step — the backend never
# acks, so the chain stalls). UNLOCK as the
# last .then() with fn=None is fine — it's
# terminal, doesn't need to chain further.
btn.click(
fn=lambda: gr.update(selected="output"),
outputs=tabs_left,
).then(
fn=_run_agent,
inputs=[
gr.State(bundle["slug"]),
gr.State(sc["origin_id"]),
gr.State(sc["dest_id"]),
gr.State(sc.get("event_ids") or []),
],
outputs=[chat, route_state, kiosk_state],
)
with gr.Tab("Custom", id="custom"):
@gr.render(inputs=current_system)
def _render_custom(system):
bundle = SYSTEMS.get(system) or SYSTEMS[DEFAULT_SYSTEM]
line_opts = _line_choices(bundle)
initial_line = line_opts[0][1] if line_opts else None
initial_stations = _stations_on_line(bundle, initial_line)
with gr.Row():
origin_line_dd = gr.Dropdown(
choices=line_opts, value=initial_line,
label="Departure line", interactive=True, scale=1,
)
origin_dd = gr.Dropdown(
choices=initial_stations, value=None,
label="Departure station", interactive=True, scale=2,
)
with gr.Row():
dest_line_dd = gr.Dropdown(
choices=line_opts, value=initial_line,
label="Arrival line", interactive=True, scale=1,
)
dest_dd = gr.Dropdown(
choices=initial_stations, value=None,
label="Arrival station", interactive=True, scale=2,
)
# Cascade: changing a line refreshes its station choices.
# The 3-phase race-free pattern: each .then() is a separate
# round-trip, and each atomic update is value-only OR
# choices-only — never both — so the Dropdown validator
# never sees a (value, choices) mismatch.
# 1. clear value (value=None, no validation)
# 2. set choices (choices=new, value still None, no
# validation)
# 3. set value (target ∈ new choices, validates)
def _refresh_clear():
return gr.update(value=None)
def _refresh_set_choices(line_id, _bundle=bundle):
return gr.update(choices=_stations_on_line(_bundle, line_id))
def _refresh_set_value(line_id, current_station, map_state, _bundle=bundle):
valid = {s["id"] for s in _bundle["stations"] if line_id in (s.get("lines") or [])}
target = next(
(c for c in (map_state, current_station) if c and c in valid),
None,
)
return gr.update(value=target)
(
origin_line_dd.change(fn=_refresh_clear, inputs=None, outputs=origin_dd)
.then(fn=_refresh_set_choices, inputs=origin_line_dd, outputs=origin_dd)
.then(
fn=_refresh_set_value,
inputs=[origin_line_dd, origin_dd, map_origin_id],
outputs=origin_dd,
)
)
(
dest_line_dd.change(fn=_refresh_clear, inputs=None, outputs=dest_dd)
.then(fn=_refresh_set_choices, inputs=dest_line_dd, outputs=dest_dd)
.then(
fn=_refresh_set_value,
inputs=[dest_line_dd, dest_dd, map_dest_id],
outputs=dest_dd,
)
)
# Map click → fills the cascading line+station dropdowns
# in two phases via .then() so the station-dropdown's
# choices and value never race. Phase 1 sets the line
# only; phase 2 (_refresh) sets the station atomically
# with the right choices for that line.
def _resolve_line(station_id, _bundle=bundle):
if not station_id:
return gr.update()
station = next(
(s for s in _bundle["stations"] if s["id"] == station_id),
None,
)
if not station:
return gr.update()
lines = station.get("lines") or []
line_id = lines[0] if lines else None
if line_id is None:
return gr.update()
return gr.update(value=line_id)
(
map_origin_id.change(fn=_resolve_line, inputs=map_origin_id, outputs=origin_line_dd)
.then(fn=_refresh_clear, inputs=None, outputs=origin_dd)
.then(fn=_refresh_set_choices, inputs=origin_line_dd, outputs=origin_dd)
.then(
fn=_refresh_set_value,
inputs=[origin_line_dd, origin_dd, map_origin_id],
outputs=origin_dd,
)
)
(
map_dest_id.change(fn=_resolve_line, inputs=map_dest_id, outputs=dest_line_dd)
.then(fn=_refresh_clear, inputs=None, outputs=dest_dd)
.then(fn=_refresh_set_choices, inputs=dest_line_dd, outputs=dest_dd)
.then(
fn=_refresh_set_value,
inputs=[dest_line_dd, dest_dd, map_dest_id],
outputs=dest_dd,
)
)
event_box = gr.CheckboxGroup(
choices=_event_chips(bundle),
label="Optional events",
)
plan_btn = gr.Button(
"Plan trip", variant="primary",
elem_id="custom-plan-btn",
)
plan_btn.click(
fn=lambda: gr.update(selected="output"),
outputs=tabs_left,
).then(
fn=_run_agent,
inputs=[gr.State(bundle["slug"]), origin_dd, dest_dd, event_box],
outputs=[chat, route_state, kiosk_state],
)
with gr.Tab("Output", id="output"):
chat = gr.Chatbot(
label="Trip plan",
show_label=False,
elem_id="chat-thread",
height=600,
buttons=[],
layout="panel",
reasoning_tags=[("<think>", "</think>")],
# Tool-call cards are inlined as <div>/<details>
# HTML; let them through. All user-derived strings
# are html.escape'd in _format_tool_card.
sanitize_html=False,
)
# Right: map only.
with gr.Column(elem_id="kiosk-output"):
# CTA row: hint text on the left, Plan-trip button on the right.
# Button is rendered always but starts in `.map-plan-disabled`
# state via JS until both map_origin_id and map_dest_id are
# populated. Gives users a path to start a rollout without
# ever opening the Custom tab.
with gr.Row(elem_id="map-cta-row"):
gr.HTML(
'<div id="map-status">Pick two stations on the map — or use Custom tab.</div>',
elem_id="map-status-wrap",
)
map_plan_btn = gr.Button(
"Plan trip", variant="primary",
elem_id="map-plan-btn",
elem_classes=["map-plan-disabled"],
)
gr.HTML('<div id="metro-map-wrap"><div id="metro-map"></div></div>')
# Kiosk-display banners (fare + advisories + action) — populated
# via RENDER_BANNERS_JS off kiosk_state. Empty until
# submit_assistant_state accepts; on mobile (≤800px) the JS
# also scrolls this into view so the banners aren't below
# the fold.
gr.HTML('<div id="kiosk-display"></div>', elem_id="kiosk-display-wrap")
# Two-part caveat: numbers are simulated (not live transit
# data) AND the model can hallucinate. Standard "AI can
# make mistakes" wording mirrors the convention visitors
# know from ChatGPT/Copilot/etc.
gr.HTML(
'<div class="kiosk-disclaimer">'
'Simulated transit data · AI can make mistakes'
'</div>',
elem_id="kiosk-disclaimer-wrap",
)
# Hidden transport: JS map clicks dispatch input events here,
# then a Python handler resolves the station ID into the
# cascading dropdowns. CSS-hidden (not visible=False) so the
# underlying <input> stays in the DOM and Gradio honours JS
# input events on it.
map_origin_id = gr.Textbox(
value="", elem_id="map-origin-id",
elem_classes=["map-state-hidden"], interactive=True,
show_label=False,
)
map_dest_id = gr.Textbox(
value="", elem_id="map-dest-id",
elem_classes=["map-state-hidden"], interactive=True,
show_label=False,
)
route_state = gr.JSON(value=None, visible=False)
kiosk_state = gr.JSON(value=None, visible=False)
# Footer — paper / repo / model card / attribution. Inside
# #kiosk-app so the existing .html-container padding reset
# reaches its Gradio wrapper.
gr.HTML(SITE_FOOTER_HTML, elem_id="site-footer-wrap")
# System change: each card-button triggers the same chain — Python fn
# returns the new state tuple (current_system, cleared chat/route/kiosk,
# cleared map-pick textboxes); JS does the side effects (highlight swap,
# Leaflet remount). Slug is closure-captured in both the Python fn (via
# default arg) and the JS (via _switch_system_js factory).
for _slug, _btn in system_btns.items():
_btn.click(
fn=(lambda s: (lambda: (s, [], None, None, "", "")))(_slug),
outputs=[current_system, chat, route_state, kiosk_state, map_origin_id, map_dest_id],
js=_switch_system_js(_slug),
).then(
fn=lambda: gr.update(selected="scenarios"),
outputs=tabs_left,
)
# Route polyline redraw on route_state change.
route_state.change(fn=None, inputs=route_state, outputs=None, js=DRAW_ROUTE_JS)
# Kiosk-display banners redraw on kiosk_state change.
kiosk_state.change(fn=None, inputs=kiosk_state, outputs=None, js=RENDER_BANNERS_JS)
# Mobile scroll-on-Plan is handled in the head HTML via a DOM event
# delegate on .scenario-card / #map-plan-btn / #custom-plan-btn —
# tabs_left.select does NOT fire when the click chain swaps tabs
# programmatically via gr.update(selected="output").
# Map Plan-trip CTA: same chain shape as the scenario / Custom-tab plan
# buttons (lock UI → flip to Output → run agent → unlock). Inputs come
# from the map-pick textboxes and the always-on current_system state;
# events default to empty (the map-only path doesn't surface optional
# disruptions — that's the Custom tab's richer job).
map_plan_btn.click(
fn=lambda: gr.update(selected="output"),
outputs=tabs_left,
).then(
fn=_run_agent,
inputs=[current_system, map_origin_id, map_dest_id, gr.State([])],
outputs=[chat, route_state, kiosk_state],
)
_ALL_GEOS_JSON = json.dumps({s: SYSTEMS[s]["geo"] for s in SYSTEMS})
_HEAD_HTML = (
'<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&family=Tektur:wght@500;700;800&display=swap" rel="stylesheet">'
'<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">'
'<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>'
# Raw <style> bypasses Gradio's CSS scoping
# (which would prepend `.gradio-container.gradio-container-6-13-0 .contain `
# to every selector and break our outer-container override). The mobile
# padding rules MUST hit `.gradio-container` itself — that element lives
# OUTSIDE `.contain`, so they only work when injected raw via `head=`.
'<style>'
'@media (max-width: 800px) {'
' .gradio-container,'
' .gradio-container.gradio-container-6-13-0 {'
' padding: 10px 8px !important;'
' }'
' .gradio-container .main.fillable {'
' padding: 0 !important;'
' }'
'}'
'</style>'
f'<script>'
f'window.METRO_GEOS = {_ALL_GEOS_JSON};'
f'window.METRO_GEO = window.METRO_GEOS[{json.dumps(DEFAULT_SYSTEM)}];'
f'window._activeSystem = {json.dumps(DEFAULT_SYSTEM)};'
f'window.METRO_DEFAULT_SYSTEM = {json.dumps(DEFAULT_SYSTEM)};'
# Timezone-based default picker. On first page load, infer the user's
# nearest system from Intl.DateTimeFormat().resolvedOptions().timeZone
# and click that system card if it differs from the Python default.
# Falls through silently when no rule matches (default stays).
# `_pickDefaultSystem(tz)` accepts an override for console testing.
'window._tzToSystem = function(tz) {'
' if (!tz) return null;'
' if (tz === "Asia/Taipei" || tz === "Asia/Hong_Kong" || tz === "Asia/Macau") return "taipei";'
' if (tz === "Asia/Shanghai" || tz === "Asia/Beijing" || tz === "Asia/Harbin" || tz === "Asia/Urumqi" || tz === "Asia/Chongqing" || tz === "Asia/Kashgar") return "beijing";'
' if (tz === "Asia/Qatar" || tz === "Asia/Bahrain" || tz === "Asia/Kuwait" || tz === "Asia/Riyadh" || tz === "Asia/Dubai" || tz === "Asia/Muscat") return "doha";'
' if (tz === "America/Chicago") return "cta";'
' if (tz === "America/Los_Angeles" || tz === "America/Vancouver" || tz === "America/Tijuana" || tz === "America/Anchorage") return "bart";'
' return null;'
'};'
'window._pickDefaultSystem = function(forceTz) {'
' var tz = forceTz || Intl.DateTimeFormat().resolvedOptions().timeZone;'
' var slug = window._tzToSystem(tz);'
' if (!slug) return {tz: tz, slug: null, action: "no-match"};'
' if (slug === window.METRO_DEFAULT_SYSTEM) return {tz: tz, slug: slug, action: "already-default"};'
' var card = document.getElementById("system-card-" + slug);'
' if (!card) return {tz: tz, slug: slug, action: "card-not-mounted-yet"};'
' card.click();'
' return {tz: tz, slug: slug, action: "clicked"};'
'};'
'window._runDefaultPick = function() {'
' if (window._defaultPicked) return;'
' var r = window._pickDefaultSystem();'
' if (r.action === "card-not-mounted-yet") { setTimeout(window._runDefaultPick, 200); return; }'
' window._defaultPicked = true;'
'};'
'if (document.readyState === "loading") {'
' document.addEventListener("DOMContentLoaded", function() { setTimeout(window._runDefaultPick, 400); });'
'} else {'
' setTimeout(window._runDefaultPick, 400);'
'}'
# Mobile-only scroll-on-Plan: a global capture-phase click delegate
# detects a Plan-triggering button (scenario card, map plan, Custom
# plan) and scrolls #kiosk-controls into view after the tab swap
# has had a chance to settle. Bypasses Gradio's tabs_left.select
# (which doesn't fire on programmatic tab changes).
'document.addEventListener("click", function(e) {'
' if (window.innerWidth > 800) return;'
' var t = e.target.closest(".scenario-card, #map-plan-btn, #custom-plan-btn");'
' if (!t) return;'
' if (t.classList && t.classList.contains("map-plan-disabled")) return;'
' setTimeout(function() {'
' var k = document.getElementById("kiosk-controls");'
' if (k) k.scrollIntoView({behavior: "smooth", block: "start"});'
' }, 250);'
'}, true);'
# Kill the first-scenario-card pulse animation as soon as the visitor
# interacts with any actionable kiosk control (scenarios row, map plan,
# Custom plan, or one of the system cards along the top). Capture-phase
# so we beat any handler that might stop propagation.
'document.addEventListener("click", function(e) {'
' var t = e.target.closest(".scenario-card, .system-card, #map-plan-btn, #custom-plan-btn");'
' if (!t) return;'
' document.body.classList.add("has-clicked");'
'}, true);'
# Highlight the default system card once Gradio's Svelte runtime mounts
# the buttons. Polls until the element exists then bails — no refresh
# spam after the first hit.
f'(function _setInitialActiveCard() {{'
f' var btn = document.getElementById("system-card-" + window._activeSystem);'
f' if (btn) {{ btn.classList.add("system-card-active"); return; }}'
f' setTimeout(_setInitialActiveCard, 150);'
f'}})();'
f'</script>'
'<script>'
'window._initMetroMap = function() {'
' var el = document.getElementById("metro-map");'
' if (!el) { setTimeout(window._initMetroMap, 200); return; }'
' if (el._leafletReady) return;'
' if (typeof L === "undefined") { setTimeout(window._initMetroMap, 100); return; }'
' el._leafletReady = true;'
' var map = L.map("metro-map", { zoomControl: true, scrollWheelZoom: false });'
' var dark = document.body.classList.contains("dark");'
# Tile-style options for eyeballing. Override defaults via ?tile=KEY in URL.
' var STYLES = {'
' stadia_smooth_dark: {url:"https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png", attr:"© Stadia Maps © OpenMapTiles © OSM"},'
' carto_dark: {url:"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", attr:"© OSM © CARTO"},'
' carto_dark_nolabels:{url:"https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png", attr:"© OSM © CARTO"},'
' stadia_outdoors: {url:"https://tiles.stadiamaps.com/tiles/outdoors/{z}/{x}/{y}{r}.png", attr:"© Stadia Maps © OpenMapTiles © OSM"},'
' stadia_toner: {url:"https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}{r}.png", attr:"© Stamen Design © Stadia Maps © OSM"},'
' stadia_toner_lite: {url:"https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}{r}.png", attr:"© Stamen Design © Stadia Maps © OSM"},'
' stadia_smooth: {url:"https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png", attr:"© Stadia Maps © OpenMapTiles © OSM"},'
' carto_positron: {url:"https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", attr:"© OSM © CARTO"},'
' carto_voyager: {url:"https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png",attr:"© OSM © CARTO"}'
' };'
' var qs = new URLSearchParams(window.location.search);'
' var override = qs.get("tile");'
' var key = override || (dark ? "stadia_smooth_dark" : "stadia_smooth");'
' var style = STYLES[key] || STYLES[dark ? "stadia_smooth_dark" : "stadia_smooth"];'
' L.tileLayer(style.url, { attribution: style.attr, maxZoom: 20 }).addTo(map);'
' var geo = window.METRO_GEO || {stations: [], edges: [], lines: []};'
' var sById = {};'
' for (var i = 0; i < geo.stations.length; i++) sById[geo.stations[i].id] = geo.stations[i];'
' var lineColor = {};'
' for (var j = 0; j < geo.lines.length; j++) {'
' var l = geo.lines[j];'
' lineColor[(l.id || (l.name||"").toLowerCase())] = l.color || "#999";'
' }'
' for (var k = 0; k < geo.edges.length; k++) {'
' var e = geo.edges[k];'
' var a = sById[e.from], b = sById[e.to];'
' if (!a || !b || a.lat == null || b.lat == null) continue;'
' var c = lineColor[(e.line || "").toLowerCase()] || "#999";'
' L.polyline([[a.lat, a.lon], [b.lat, b.lon]], {color: c, weight: 3, opacity: 0.45}).addTo(map);'
' }'
# Two markers per station:
# 1. A small visible dot (radius 3, non-interactive — purely visual).
# 2. A larger invisible "hit" circle (radius 12, interactive) that owns
# the click handler + hover tooltip. The hit-area gives mobile fingers
# a generous tap target and lets Leaflet do its native double-tap-
# to-zoom on empty map space (we no longer wire map.on("click")).
# _stationMarkers tracks the HIT marker (where the tooltip lives) so the
# suppress/restore helpers in the pick handler keep working.
' window._stationMarkers = {};'
' window._stationNames = {};'
' for (var n = 0; n < geo.stations.length; n++) {'
' var s = geo.stations[n];'
' if (s.lat == null) continue;'
' L.circleMarker([s.lat, s.lon], {'
' radius: 3, color: "#666", fillColor: "#fff", fillOpacity: 1, weight: 1.5,'
' interactive: false'
' }).addTo(map);'
' var hit = L.circleMarker([s.lat, s.lon], {'
' radius: 12, fillOpacity: 0, opacity: 0, weight: 0'
' }).addTo(map).bindTooltip(s.name);'
' hit.station = s;'
' hit.on("click", function(ev) {'
' L.DomEvent.stopPropagation(ev);'
' window._pickStation(this.station);'
' });'
' window._stationMarkers[s.id] = hit;'
' window._stationNames[s.id] = s.name;'
' }'
' var lats = [], lons = [];'
' for (var p = 0; p < geo.stations.length; p++) {'
' if (geo.stations[p].lat != null) { lats.push(geo.stations[p].lat); lons.push(geo.stations[p].lon); }'
' }'
' if (lats.length) {'
' map.fitBounds([[Math.min.apply(null, lats), Math.min.apply(null, lons)], '
' [Math.max.apply(null, lats), Math.max.apply(null, lons)]], {padding: [16, 16]});'
' }'
' window._metroMap = map;'
' window._routeLayer = null;'
' window._endpointLayers = [];'
' window._mapClickMode = window._mapClickMode || "origin";'
' window._originPickMarker = null;'
' window._destPickMarker = null;'
# No map.on("click", ...) — picking is now driven by per-station hit
# markers. Empty-area taps no-op so Leaflet's built-in doubleClickZoom
# works correctly on touch.
'};'
# Helper: write a value into a hidden gr.Textbox so Gradio picks it up
# on the next interaction. We use the native value setter so Svelte
# state stays in sync.
'window._setHiddenGrInput = function(wrapId, value) {'
' var wrap = document.getElementById(wrapId);'
' if (!wrap) return;'
' var input = wrap.querySelector("input, textarea");'
' if (!input) return;'
' var proto = Object.getPrototypeOf(input);'
' var desc = Object.getOwnPropertyDescriptor(proto, "value");'
' if (desc && desc.set) desc.set.call(input, value);'
' else input.value = value;'
' input.dispatchEvent(new Event("input", {bubbles: true}));'
'};'
# Pick a specific station (called from per-station hit-marker click).
# Toggles origin/destination state; the rest is the same as before.
'window._pickStation = function(best) {'
' if (!best) return;'
' var coord = [best.lat, best.lon];'
' var map = window._metroMap;'
' var st = document.getElementById("map-status");'
# Pick markers carry a permanent tooltip ("Origin: X" / "Destination: X").
# To avoid stacking popovers with the underlying station's hover tooltip,
# we unbind the station's tooltip while it's the picked one and rebind
# when the pick is cleared. Helpers _suppressStationTip / _restoreStationTip
# do the bookkeeping.
' var _suppress = function(id) {'
' var sm = window._stationMarkers && window._stationMarkers[id];'
' if (sm && sm.getTooltip()) sm.unbindTooltip();'
' };'
' var _restore = function(id) {'
' var sm = window._stationMarkers && window._stationMarkers[id];'
' var nm = window._stationNames && window._stationNames[id];'
' if (sm && nm && !sm.getTooltip()) sm.bindTooltip(nm);'
' };'
' if (window._mapClickMode === "origin") {'
# Restore tooltips on whatever was picked before; this run is a fresh trip.
' if (window._originPickId) _restore(window._originPickId);'
' if (window._destPickId) _restore(window._destPickId);'
' if (window._originPickMarker) map.removeLayer(window._originPickMarker);'
' if (window._destPickMarker) { map.removeLayer(window._destPickMarker); window._destPickMarker = null; window._destPickId = null; window._destPickName = null; }'
' window._setHiddenGrInput("map-dest-id", "");'
' window._originPickMarker = L.circleMarker(coord, {'
' radius: 9, color: "#198038", fillColor: "#42BE65", fillOpacity: 1, weight: 3,'
' interactive: false'
' }).addTo(map).bindTooltip("Origin: " + best.name, {permanent: true, direction: "auto", offset: [10, 0], interactive: false});'
' window._originPickId = best.id;'
' window._originPickName = best.name;'
' _suppress(best.id);'
' window._setHiddenGrInput("map-origin-id", best.id);'
' window._mapClickMode = "dest";'
' if (st) st.textContent = "Origin: " + best.name + " · pick destination";'
' } else {'
# Replacing dest: restore the previously-picked dest station's tooltip.
' if (window._destPickId) _restore(window._destPickId);'
' if (window._destPickMarker) map.removeLayer(window._destPickMarker);'
' window._destPickMarker = L.circleMarker(coord, {'
' radius: 9, color: "#DA1E28", fillColor: "#FA4D56", fillOpacity: 1, weight: 3,'
' interactive: false'
' }).addTo(map).bindTooltip("Destination: " + best.name, {permanent: true, direction: "auto", offset: [10, 0], interactive: false});'
' window._destPickId = best.id;'
' window._destPickName = best.name;'
' _suppress(best.id);'
' window._setHiddenGrInput("map-dest-id", best.id);'
' var oName = window._originPickName || "(origin)";'
' window._mapClickMode = "origin";'
' if (st) st.textContent = oName + " → " + best.name;'
' }'
' if (window._refreshMapPlanBtn) window._refreshMapPlanBtn();'
'};'
# Toggle the Plan-trip button between disabled (dim, no clicks) and
# ready (full opacity, clickable). The button is active iff both
# hidden textboxes have non-empty values.
'window._refreshMapPlanBtn = function() {'
' var btn = document.getElementById("map-plan-btn");'
' var oWrap = document.getElementById("map-origin-id");'
' var dWrap = document.getElementById("map-dest-id");'
' if (!btn || !oWrap || !dWrap) return;'
' var o = (oWrap.querySelector("input, textarea") || {}).value || "";'
' var d = (dWrap.querySelector("input, textarea") || {}).value || "";'
' var ready = o.length > 0 && d.length > 0;'
' btn.classList.toggle("map-plan-disabled", !ready);'
'};'
# Wire input listeners on the two hidden textboxes so the button also
# tracks Custom-tab dropdown changes (which write back to the same
# textboxes via the cascade) — not just direct map clicks.
'window._wireMapPlanBtn = function() {'
' if (window._mapPlanWired) return;'
' var oWrap = document.getElementById("map-origin-id");'
' var dWrap = document.getElementById("map-dest-id");'
' if (!oWrap || !dWrap) { setTimeout(window._wireMapPlanBtn, 200); return; }'
' var oI = oWrap.querySelector("input, textarea");'
' var dI = dWrap.querySelector("input, textarea");'
' if (!oI || !dI) { setTimeout(window._wireMapPlanBtn, 200); return; }'
' oI.addEventListener("input", window._refreshMapPlanBtn);'
' dI.addEventListener("input", window._refreshMapPlanBtn);'
' window._mapPlanWired = true;'
' window._refreshMapPlanBtn();'
'};'
'if (document.readyState === "loading") {'
' document.addEventListener("DOMContentLoaded", function() {'
' window._initMetroMap();'
' window._wireMapPlanBtn();'
' });'
'} else {'
' window._initMetroMap();'
' window._wireMapPlanBtn();'
'}'
'</script>'
)
# Gradio theme: color tokens live here (auto-swap light/dark), so the CSS
# below only does layout-specific work (header, scenario cards, hard shadows).
KIOSK_THEME = gr.themes.Default(
font=[gr.themes.GoogleFont("IBM Plex Sans"), "system-ui", "sans-serif"],
font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace"],
radius_size="none",
).set(
# body / surfaces
body_background_fill="#F6F7F9",
body_background_fill_dark="#14171A",
body_text_color="#2C3338",
body_text_color_dark="#E8ECEF",
body_text_color_subdued="#3A4654",
body_text_color_subdued_dark="#B0B6BD",
# blocks
block_background_fill="#F6F7F9",
block_background_fill_dark="#14171A",
block_label_background_fill="transparent",
block_label_background_fill_dark="transparent",
block_label_text_color="#2C3338",
block_label_text_color_dark="#E8ECEF",
block_border_color="transparent",
block_border_color_dark="transparent",
block_title_text_color="#2C3338",
block_title_text_color_dark="#E8ECEF",
# inputs (dropdown, textbox, etc.)
input_background_fill="#F6F7F9",
input_background_fill_dark="#14171A",
input_background_fill_focus="#ECEEF1",
input_background_fill_focus_dark="#1E2227",
input_border_color="#6A737D",
input_border_color_dark="#3A424B",
input_border_color_focus="#C21924",
input_border_color_focus_dark="#FA4D56",
# primary button
button_primary_background_fill="#0F1419",
button_primary_background_fill_dark="#E8ECEF",
button_primary_background_fill_hover="#000000",
button_primary_background_fill_hover_dark="#FFFFFF",
button_primary_text_color="#F6F7F9",
button_primary_text_color_dark="#14171A",
button_primary_border_color="#0F1419",
button_primary_border_color_dark="#E8ECEF",
# secondary button
button_secondary_background_fill="#ECEEF1",
button_secondary_background_fill_dark="#1E2227",
button_secondary_background_fill_hover="#DEE2E6",
button_secondary_background_fill_hover_dark="#2A3038",
button_secondary_text_color="#2C3338",
button_secondary_text_color_dark="#E8ECEF",
button_secondary_border_color="#6A737D",
button_secondary_border_color_dark="#3A424B",
# global border
border_color_primary="#6A737D",
border_color_primary_dark="#3A424B",
border_color_accent="#C21924",
border_color_accent_dark="#FA4D56",
)
if __name__ == "__main__":
demo.launch(
server_name="0.0.0.0",
server_port=int(os.environ.get("PORT", "7860")),
ssr_mode=False,
css=CUSTOM_CSS,
head=_HEAD_HTML,
theme=KIOSK_THEME,
)