"""Liquid-flow visual theme for CityQuest-AI.
A minimalist pale/beige palette with a slowly drifting "water" gradient,
soft pill buttons with a tap ripple, rounded translucent cards, clear sans
typography, and hover tooltips injected onto every ``elem_id``'d control.
Exports ``CSS`` (stylesheet) and ``JS_INIT`` (tooltip + ripple bootstrap),
plus :func:`gradio_theme` for the base Gradio theme object.
"""
import gradio as gr
# ── Tooltip copy keyed by elem_id (applied via JS) ───────────────────────────
TOOLTIPS = {
"home-create": "Start a new adventure and become the host",
"home-join": "Enter a room code to join a friend's adventure",
"create-submit": "Create the room and open the lobby",
"create-back": "Back to the start",
"join-submit": "Join this room",
"join-back": "Back to the start",
"lobby-start": "Generate tasks and send every team into play",
"lobby-leave": "Leave this room",
"td-transcribe": "Transcribe your recording with Cohere ASR",
"td-savejournal": "Save this voice/typed note — you can add several per task",
"td-addphoto": "Attach this photo with its tagline (add as many as you like)",
"td-ask": "Ask the in-game guide about this task or place",
"td-complete": "Mark this task done — needs a photo or a journal first",
"td-prev": "Previous task",
"td-next": "Next task",
"td-finish": "Finish and wait for the other teams",
"play-finish": "Finish and wait for the other teams",
"wait-funny-recap": "Make a light, funny recap of your run so far",
"wait-funny-post": "Make a playful poster from your photos",
"win-recap": "Generate the winning episode recap",
"win-endpost": "Generate an ending poster",
"win-grouppost": "Generate a group celebration poster",
"win-new": "Start a brand new adventure",
}
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&display=swap');
:root {
/* ── High-contrast dark theme (WCAG AAA, white-on-dark) ──
Raw palette tokens (bright accents, used directly by some rules) … */
--green: #4CAF50; /* bright green — primary buttons / success */
--green-h: #45A049; /* green hover */
--green-a: #3D8B40; /* green active/pressed */
--blue: #2196F3; /* bright blue — interactive */
--purple: #9C27B0; /* bright purple — CTAs */
--yellow: #FFC107; /* bright amber — warnings / highlights */
--red: #F44336; /* bright red — errors */
/* … and aliases onto the names the rest of the stylesheet + app.py use,
so the whole UI re-skins from this one block. */
--sand: #1A1A1A; /* page base — darkest */
--sand-2: #222222;
--paper: #2A2A2A; /* card surface */
--paper-2: #3A3A3A; /* inputs / soft surfaces / code */
--line: #4A4A4A; /* borders — visible on dark */
--clay: #FFB74D; /* warm amber accent */
--clay-dk: #FFC107; /* bright amber — used as accent text */
--teal: #4CAF50; /* bright green accent (fills + borders) */
--teal-dk: #66BB6A; /* lighter green — accent text on dark cards */
--ink: #FFFFFF; /* primary text — white, no exceptions */
--muted: #CCCCCC; /* secondary text — light grey */
--good: #4CAF50;
--radius: 18px;
}
/* Force our palette even if Gradio applies its dark class (we also force
light via the URL param in JS, this is a belt-and-braces override). */
.dark { --body-text-color: var(--ink); --body-background-fill: var(--sand); }
/* ── Flowing water background ── */
body, .gradio-container, .dark .gradio-container {
background: var(--sand) !important;
color: var(--ink) !important;
font-family: 'Inter', system-ui, sans-serif !important;
}
.gradio-container::before {
content: '';
position: fixed; inset: -40%;
z-index: -1;
background:
radial-gradient(40% 55% at 20% 30%, rgba(76,175,80,0.12), transparent 60%), /* green glow */
radial-gradient(45% 50% at 80% 25%, rgba(33,150,243,0.12), transparent 60%), /* blue glow */
radial-gradient(50% 60% at 60% 85%, rgba(156,39,176,0.10), transparent 60%), /* purple glow */
radial-gradient(45% 55% at 15% 80%, rgba(255,193,7,0.07), transparent 60%); /* amber glow */
background-size: 130% 130%;
animation: drift 26s ease-in-out infinite alternate;
filter: blur(8px);
pointer-events: none;
}
@keyframes drift {
0% { background-position: 30% 35%; }
50% { background-position: 60% 50%; }
100% { background-position: 45% 65%; }
}
/* Make every full-screen wrapper transparent so the drifting aura gradient
(.gradio-container::before) shows through.
stays the dark base. */
gradio-app,
.gradio-container, .dark .gradio-container,
.gradio-container > .main,
.gradio-container .wrap,
.gradio-container .contain {
background: transparent !important;
}
footer { display: none !important; }
/* ── Screen fade/slide transitions ── */
.cq-screen { animation: flow-in .55s cubic-bezier(.22,.61,.36,1); }
@keyframes flow-in {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Cards ── */
.cq-card {
background: rgba(42,42,42,0.92) !important;
border: 1px solid var(--line) !important;
border-radius: var(--radius) !important;
padding: 26px !important;
backdrop-filter: blur(6px);
box-shadow: 0 10px 40px -20px rgba(0,0,0,0.6);
}
.cq-soft { background: var(--paper-2) !important; border:1px solid var(--line) !important;
border-radius: var(--radius) !important; padding: 18px !important; }
/* ── Landing page refinements (scoped to home screen only) ── */
/* Remove the grey rectangle/background around the whole home screen */
.cq-home, .cq-home > .block, .cq-home > div {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
/* Bigger title "logo" on the landing screen */
.cq-home .cq-title {
font-size: clamp(2.6rem, 7vw, 4.2rem) !important;
}
/* CHANGE 1: Remove borders / box outlines from the home cards so they float
cleanly against the background — other screens keep their .cq-card chrome. */
.cq-home-card,
.cq-home-card > .block,
.cq-home-card > div,
.cq-home-card .form,
.cq-home .gr-column,
.cq-home .gr-column > .block {
border: none !important;
box-shadow: none !important;
outline: none !important;
background: transparent !important;
backdrop-filter: none !important;
}
/* CHANGE 3B: Centered, larger description above each button */
.cq-home .landing-description {
text-align: center !important;
font-size: 1.1rem !important;
color: #CCCCCC !important;
margin-bottom: 16px !important;
line-height: 1.5 !important;
}
/* CHANGE 3A: Bigger, bolder full-width buttons. CHANGE 4: hover/active animation */
.cq-home .landing-btn {
width: 100% !important;
padding: 20px 32px !important;
font-size: 1.2rem !important;
font-weight: 700 !important;
border-radius: 16px !important;
letter-spacing: 0.5px !important;
min-height: 64px !important;
transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease !important;
cursor: pointer !important;
}
/* Make both landing buttons the same colour (green) */
.cq-home .landing-btn.secondary {
background: var(--green) !important;
border: none !important;
color: #000000 !important;
box-shadow: 0 8px 24px -14px rgba(76,175,80,0.8) !important;
}
.cq-home .landing-btn.secondary:hover {
background: var(--green-h) !important;
border: none !important;
color: #000000 !important;
}
.cq-home .landing-btn:hover {
transform: scale(1.03) !important;
filter: brightness(1.1) !important;
box-shadow: 0 8px 24px rgba(76,175,80,0.4) !important;
}
.cq-home .landing-btn:active {
transform: scale(0.98) !important;
filter: brightness(0.95) !important;
}
/* ── Create screen refinements (scoped to .cq-create only) ── */
/* Remove the outer grey rectangle around the whole screen. Each screen's own
gr.Group (.cq-screen) renders Gradio's default card chrome; strip it so the
inner .cq-card floats against the animated background. Scoped to the group's
own wrapper layers so the inner .cq-card keeps its border/fill. Applies to
every screen (create, join, lobby, play, wait, win). */
.cq-screen,
.cq-screen > .block,
.cq-screen > div:not(.cq-card),
.cq-screen > .block > div:not(.cq-card) {
background: transparent !important;
border: none !important;
box-shadow: none !important;
backdrop-filter: none !important;
}
/* CHANGE 1: Give the dropdown fields (game type, difficulty, age group, energy)
the same grey opaque input box as the textbox fields. Gradio renders a
dropdown's display element as .wrap (not a typed ), so it never picked
up the input fill — this makes them visually match and read as editable. */
.cq-create .cq-dropdown .wrap {
background: var(--paper-2) !important;
border: 1px solid var(--line) !important;
border-radius: 12px !important;
color: var(--ink) !important;
}
/* CHANGE 4: Center the live game-parameter summary bar */
.cq-create .game-summary,
.cq-create .game-summary .cq-soft {
text-align: center !important;
width: 100% !important;
letter-spacing: 0.5px !important;
}
.cq-create .game-summary .cq-soft {
border: none !important;
background: transparent !important;
}
/* CHANGE 5: Bigger buttons with hover/active animation (matches landing page) */
.cq-create .create-btn {
transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease !important;
cursor: pointer !important;
padding: 18px 32px !important;
font-size: 1.1rem !important;
font-weight: 700 !important;
border-radius: 12px !important;
min-height: 60px !important;
}
.cq-create .open-lobby-btn:hover {
transform: scale(1.03) !important;
box-shadow: 0 8px 24px rgba(76,175,80,0.4) !important;
filter: brightness(1.1) !important;
}
.cq-create .back-btn:hover {
transform: scale(1.03) !important;
box-shadow: 0 8px 24px rgba(255,255,255,0.1) !important;
filter: brightness(1.15) !important;
}
.cq-create .create-btn:active {
transform: scale(0.98) !important;
filter: brightness(0.95) !important;
}
/* ── Win screen: black-card reskin + symmetric columns ─────────── */
/* The Celebrate card overrides the default .cq-card grey fill with
pure black and a stronger rounded border, scoped to .cq-win only. */
.cq-win .cq-win-celebrate.cq-card {
background: #0A0A0A !important;
border: 1px solid rgba(255,255,255,0.08) !important;
border-radius: 20px !important;
padding: 28px !important;
backdrop-filter: none !important;
box-shadow: 0 16px 48px -24px rgba(0,0,0,0.8) !important;
}
/* Strip the default grey block/form fills from inner wrappers inside the
Celebrate card so only the black card shows through. Buttons ()
and the poster drop-zone (.image-container) keep their own styling. */
.cq-win .cq-win-celebrate > div,
.cq-win .cq-win-celebrate .form,
.cq-win .cq-win-celebrate .block:not(.image-container) {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
/* Final Standings column gets a matching black card so the two
columns have equal visual weight. */
.cq-win .cq-win-standings {
background: #0A0A0A !important;
border: 1px solid rgba(255,255,255,0.08) !important;
border-radius: 20px !important;
padding: 28px !important;
box-shadow: 0 16px 48px -24px rgba(0,0,0,0.8) !important;
margin-bottom: 24px !important;
}
/* Strip any inherited chrome from inner blocks inside the standings
card so the markdown sits flush. */
.cq-win .cq-win-standings > div,
.cq-win .cq-win-standings .block {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
/* Win-screen buttons: match landing-page scale+brightness animation. */
.cq-win .cq-win-btn {
transition: transform 0.2s ease, box-shadow 0.2s ease,
filter 0.2s ease !important;
cursor: pointer !important;
}
.cq-win .cq-win-btn:hover {
transform: scale(1.03) !important;
filter: brightness(1.1) !important;
}
.cq-win .cq-win-btn:active {
transform: scale(0.98) !important;
filter: brightness(0.95) !important;
}
/* Start-a-new-adventure: full-width primary CTA with green accent so
it reads as a deliberate next-step, not an orphaned secondary. */
.cq-win .cq-win-new {
width: 100% !important;
margin-top: 24px !important;
padding: 18px 32px !important;
font-size: 1.1rem !important;
font-weight: 700 !important;
border-radius: 14px !important;
background: var(--green) !important;
color: #000 !important;
border: none !important;
box-shadow: 0 8px 24px -14px rgba(76,175,80,0.8) !important;
}
/* Recap markdown breathing room: add spacing between header hierarchy
so the long recap doesn't read as a wall. */
.cq-win .cq-win-celebrate h1,
.cq-win .cq-win-celebrate h2,
.cq-win .cq-win-celebrate h3 {
margin-top: 1.6em !important;
margin-bottom: 0.6em !important;
}
.cq-win .cq-win-celebrate h1:first-child,
.cq-win .cq-win-celebrate h2:first-child,
.cq-win .cq-win-celebrate h3:first-child {
margin-top: 0 !important;
}
/* Poster empty-state: soft dashed drop-zone instead of debug-looking
default placeholder. */
.cq-poster .image-container:not(:has(img)),
.cq-win .cq-win-celebrate .image-container:not(:has(img)) {
background: rgba(255,255,255,0.02) !important;
border: 1.5px dashed rgba(255,255,255,0.12) !important;
border-radius: 14px !important;
min-height: 240px !important;
position: relative !important;
}
.cq-poster .image-container:not(:has(img))::after,
.cq-win .cq-win-celebrate .image-container:not(:has(img))::after {
content: 'Poster will appear here' !important;
position: absolute !important;
top: 50% !important; left: 50% !important;
transform: translate(-50%, -50%) !important;
color: var(--muted) !important;
font-size: 0.9rem !important;
text-align: center !important;
pointer-events: none !important;
}
/* ── Headings ── */
.cq-title {
font-family: 'Fraunces', serif !important;
font-weight: 600 !important;
color: var(--ink) !important;
letter-spacing: -0.01em !important;
}
.gr-markdown h1, .gr-markdown h2, .gr-markdown h3 {
font-family: 'Fraunces', serif !important; color: var(--ink) !important;
font-weight: 600 !important;
}
.gr-markdown { color: var(--ink) !important; line-height: 1.7 !important; }
.gr-markdown code {
background: var(--paper-2) !important; color: var(--clay-dk) !important;
border-radius: 6px !important; padding: 2px 7px !important;
}
.section-label {
font-size: 11px !important; font-weight: 700 !important;
letter-spacing: 0.14em !important; text-transform: uppercase !important;
color: var(--muted) !important; margin: 6px 0 10px !important;
display: flex; align-items: center; gap: 10px;
}
.section-label::after { content:''; flex:1; height:1px; background: var(--line); }
/* ── Inputs ── */
input[type="text"], input[type="number"], textarea, select, .gr-input {
background: var(--paper-2) !important; /* dark input fields */
border: 1px solid var(--line) !important;
border-radius: 12px !important;
color: var(--ink) !important;
font-family: 'Inter', sans-serif !important;
}
input:focus, textarea:focus, select:focus {
border-color: var(--teal) !important; outline: none !important;
box-shadow: 0 0 0 4px rgba(76,175,80,0.35) !important;
}
label span, .label-wrap span {
font-size: 11px !important; font-weight: 700 !important;
letter-spacing: 0.1em !important; text-transform: uppercase !important;
color: var(--ink) !important;
}
/* ── Dropdown menu positioning fix ──
Cards use backdrop-filter (see .cq-card), which creates a containing block
that clips the dropdown's default position:fixed menu — so the game-type
(and other) dropdown menus rendered off-position / overlapped neighbouring
fields. Render the listbox in-flow (static) directly under its input so it
can't be clipped, sits full-width below the field, and never overlaps. */
.cq-card, .cq-screen { overflow: visible !important; }
ul.options[role="listbox"] {
position: static !important;
width: 100% !important;
max-height: 260px !important;
overflow-y: auto !important;
margin-top: 4px !important;
z-index: 50 !important;
background: var(--paper) !important;
border: 1px solid var(--line) !important;
border-radius: 12px !important;
box-shadow: 0 12px 28px -16px rgba(62,58,51,0.5) !important;
}
ul.options[role="listbox"] li {
color: var(--ink) !important;
border-radius: 8px !important;
}
/* ── Buttons: soft water pills ── */
.gr-button, button.lg, button.sm {
font-family: 'Inter', sans-serif !important;
font-weight: 600 !important;
border-radius: 999px !important;
transition: transform .18s cubic-bezier(.22,.61,.36,1), box-shadow .2s, background .2s !important;
position: relative; overflow: hidden;
}
.gr-button.primary {
background: var(--green) !important; /* bright green */
color: #000000 !important; /* black text — 5.2:1 on green */
border: none !important;
box-shadow: 0 8px 24px -14px rgba(76,175,80,0.8) !important;
}
.gr-button.primary:hover { background: var(--green-h) !important;
color: #000000 !important; transform: translateY(-2px) !important;
box-shadow: 0 14px 30px -14px rgba(76,175,80,0.65) !important; }
.gr-button.primary:active { background: var(--green-a) !important; }
.gr-button.secondary {
background: var(--paper-2) !important;
border: 2px solid var(--green) !important; color: var(--ink) !important; /* white text */
font-weight: 600 !important;
}
.gr-button.secondary:hover { background: #4A4A4A !important;
border-color: var(--green-h) !important; color: var(--ink) !important;
transform: translateY(-1px) !important; box-shadow: 0 8px 20px -16px rgba(76,175,80,0.6) !important; }
.gr-button[disabled], .gr-button.disabled { opacity: .5 !important; filter: grayscale(.25);
cursor: not-allowed !important; transform: none !important; }
/* Wait/win-screen secondaries: kill Gradio's default purple gradient and
match the themed grey-outline style of .gr-button.secondary. */
#wait-funny-recap button, #wait-funny-post button,
#win-endpost button, #win-grouppost button {
background: var(--paper-2) !important;
border: 2px solid var(--green) !important;
color: var(--ink) !important;
background-image: none !important;
}
/* ── Force readable text everywhere (beats Gradio theme defaults) ── */
.gradio-container, .gr-markdown, .gr-markdown p, .gr-markdown li,
.prose, .prose p, .prose li, p, span:not(.cq-pill):not([class*='cq-']) {
color: var(--ink);
}
.gr-markdown strong, .gr-markdown b { color: var(--ink) !important; }
.gr-check-radio label, .wrap label { color: var(--ink) !important; }
/* tap ripple */
.cq-ripple { position:absolute; border-radius:50%; transform: scale(0);
background: rgba(255,255,255,0.5); animation: ripple .6s ease-out; pointer-events:none; }
@keyframes ripple { to { transform: scale(3.2); opacity: 0; } }
/* ── Big code chip ── */
.cq-code {
font-family: 'Fraunces', serif; font-weight: 700;
letter-spacing: 0.32em; font-size: clamp(2rem, 7vw, 3.4rem);
color: var(--clay-dk); background: var(--paper);
border: 1px dashed var(--clay); border-radius: var(--radius);
padding: 14px 26px; display: inline-block;
}
/* ── Lobby: room-code hint (replaces the redundant "SHARE…" label) ── */
.cq-code-hint {
text-align: center; color: var(--muted);
font-size: 0.95rem; margin-bottom: 10px;
}
/* ── Lobby: copy-to-clipboard button beside the room code ── */
.cq-copy-wrap { display: flex; justify-content: center; margin-top: 12px; }
.cq-copy-btn {
background: var(--paper-2); color: var(--ink);
border: 1px solid var(--line); border-radius: 999px;
padding: 8px 20px; font-size: 0.95rem; font-family: 'Inter', sans-serif;
font-weight: 600; cursor: pointer; transition: all 0.2s ease;
}
.cq-copy-btn:hover {
background: #4A4A4A; border-color: var(--green); transform: scale(1.03);
}
.cq-copy-btn.copied {
background: rgba(76,175,80,0.22); border-color: var(--green); color: var(--ink);
transform: scale(1.0);
}
/* ── Lobby: team-pick buttons — selected state ── */
.team-pick.selected button {
background: rgba(76,175,80,0.20) !important;
border: 2px solid var(--green) !important;
color: var(--ink) !important; font-weight: 700 !important;
box-shadow: 0 0 12px rgba(76,175,80,0.3) !important;
}
/* ── Lobby: "N / M teams ready" progress bar ── */
.cq-progress { margin: 2px 0 6px 0; }
.cq-progress-label {
font-size: 0.85rem; color: var(--muted); margin-bottom: 6px; display: block;
}
.cq-progress-track {
height: 6px; background: var(--paper-2); border-radius: 3px; overflow: hidden;
}
.cq-progress-fill {
height: 100%; background: var(--green); border-radius: 3px;
transition: width 0.4s ease;
}
/* ── Lobby: center the prep status pill (loading / ready) ── */
.lobby-prep { text-align: center; }
/* ── Lobby: host/guest instruction line ── */
.lobby-hint, .lobby-hint p {
text-align: center; color: var(--clay-dk) !important;
font-size: 1rem !important; font-weight: 500 !important;
background: rgba(255,193,7,0.08); border-radius: 8px;
padding: 8px 16px; margin: 8px 0 !important;
}
/* ── Task wall ── */
.cq-wall { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px,1fr)); gap: 14px; }
.cq-tcard {
background: var(--paper); border:1px solid var(--line); border-radius: 16px;
padding: 16px; transition: transform .18s, box-shadow .2s; cursor: pointer;
}
.cq-tcard:hover { transform: translateY(-3px); box-shadow: 0 12px 28px -18px rgba(62,58,51,.5); }
.cq-tcard.done { background: linear-gradient(135deg, rgba(110,157,144,.16), var(--paper)); border-color: var(--teal); }
.cq-check { float:right; font-size: 18px; }
.cq-pts { color: var(--clay-dk); font-weight: 700; font-size: 12px; }
/* ── Status / timer strip ── */
.cq-strip { display:flex; gap:10px; flex-wrap:wrap; }
.cq-pill {
background: var(--paper); border:1px solid var(--line); border-radius:999px;
padding: 6px 14px; font-size: 13px; color: var(--ink);
}
/* Pills ease their colour/state changes so polled updates glide instead of
snapping. A CSS transition (not an entry keyframe) is safe to apply to
fragments that re-render every poll tick — it won't re-trigger/flicker. */
.cq-pill { transition: background .35s ease, border-color .35s ease, color .35s ease; }
.cq-pill.live { border-color: var(--teal); color: var(--teal-dk); }
.cq-pill.wait { border-color: var(--clay); color: var(--clay-dk);
background: rgba(255,193,7,0.15); }
.cq-pill.done { background: var(--teal); color:#0A1F0A; border-color: var(--teal); }
.cq-pill.alert { border-color: var(--red); color: #FF8A80; background: rgba(244,67,54,0.18); }
.cq-timer { font-family:'Fraunces',serif; font-weight:700; font-size: clamp(1.6rem,5vw,2.4rem);
color: var(--teal-dk); transition: color .35s ease; }
/* ── Reveal animation for on-demand panels (task detail, etc.) ──
Only applied to elements that toggle on user action — NOT to polled
fragments — so the keyframe never re-fires on a refresh tick. */
.cq-reveal { animation: cq-reveal .42s cubic-bezier(.22,.61,.36,1); }
@keyframes cq-reveal {
from { opacity: 0; transform: translateY(10px) scale(.99); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Gentle fade for hint/safety blockquotes inside a revealed detail panel. */
.cq-reveal blockquote { animation: cq-fadein .6s ease both; animation-delay: .1s; }
@keyframes cq-fadein { from { opacity: 0; } to { opacity: 1; } }
/* ── Loading / buffer states ── */
.cq-loading {
display: inline-flex; align-items: center; gap: 10px;
color: var(--teal-dk); font-weight: 600; font-family: 'Inter', sans-serif;
background: rgba(110,157,144,0.12); border: 1px solid #CFE0D9;
border-radius: 999px; padding: 8px 16px; margin: 6px 0;
animation: cq-fade 1.5s ease-in-out infinite;
}
.cq-loading::before {
content: ''; width: 14px; height: 14px; border-radius: 50%;
border: 2.5px solid #CFE0D9; border-top-color: var(--teal-dk);
display: inline-block; animation: cq-spin .8s linear infinite;
}
@keyframes cq-spin { to { transform: rotate(360deg); } }
@keyframes cq-fade { 0%,100% { opacity: .6; } 50% { opacity: 1; } }
.cq-ready {
display: inline-flex; align-items: center; gap: 8px; margin: 6px 0;
color: var(--teal-dk); font-weight: 600; font-family: 'Inter', sans-serif;
background: rgba(110,157,144,0.16); border: 1px solid var(--teal);
border-radius: 999px; padding: 8px 16px;
}
/* Gradio's own progress bar, tinted to the palette */
.progress-bar, .progress-level-inner { background: var(--teal) !important; color: var(--teal-dk) !important; }
.gallery-item, .image-frame { background: var(--paper-2) !important; }
/* ── Tooltips (data-tip injected by JS) ── */
[data-tip] { position: relative; }
[data-tip]:hover::after {
content: attr(data-tip);
position: absolute; bottom: calc(100% + 9px); left: 50%; transform: translateX(-50%);
background: var(--ink); color: var(--sand); font-family:'Inter',sans-serif;
font-size: 12px; font-weight: 500; line-height: 1.35; letter-spacing: 0;
text-transform: none; padding: 7px 11px; border-radius: 10px; white-space: normal;
width: max-content; max-width: 230px; z-index: 999; pointer-events: none;
box-shadow: 0 8px 24px -10px rgba(0,0,0,.4); animation: flow-in .18s ease;
}
[data-tip]:hover::before {
content: ''; position: absolute; bottom: calc(100% + 3px); left: 50%;
transform: translateX(-50%); border: 6px solid transparent; border-top-color: var(--ink);
z-index: 999; pointer-events: none;
}
/* scrollbars */
::-webkit-scrollbar { width: 7px; height: 7px; }
::-webkit-scrollbar-thumb { background: var(--line); border-radius: 99px; }
/* ── Horizontal task tab row (in the task detail view) ── */
.task-tab-row {
display: flex !important;
flex-direction: row !important;
overflow-x: auto !important;
gap: 8px !important;
padding: 10px 0 !important;
flex-wrap: nowrap !important;
scrollbar-width: thin;
scrollbar-color: var(--green) var(--paper);
}
.task-tab {
background-color: var(--paper) !important;
color: var(--muted) !important;
border: 1.5px solid var(--paper-2) !important;
border-radius: 20px !important;
padding: 8px 16px !important;
min-width: 0 !important;
font-size: 0.85rem !important;
font-weight: 500 !important;
white-space: nowrap !important;
flex-shrink: 0 !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
}
.task-tab:hover {
border-color: var(--green) !important;
color: var(--ink) !important;
transform: scale(1.03) !important;
}
.task-tab-active {
background-color: #1b5e20 !important;
border-color: var(--green) !important;
color: var(--ink) !important;
font-weight: 700 !important;
box-shadow: 0 0 10px rgba(76, 175, 80, 0.3) !important;
}
.task-tab-done {
border-color: var(--green) !important;
color: var(--green) !important;
opacity: 0.8 !important;
}
.task-tab-active.task-tab-done {
background-color: #1b5e20 !important;
color: var(--ink) !important;
opacity: 1 !important;
}
/* ── Prev / Next task navigation ── */
.task-nav-row {
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
margin: 8px 0 !important;
}
.task-nav-btn {
background-color: var(--paper-2) !important;
color: var(--muted) !important;
border: 1px solid var(--line) !important;
border-radius: 8px !important;
padding: 6px 14px !important;
font-size: 0.85rem !important;
transition: all 0.2s ease !important;
}
.task-nav-btn:hover {
border-color: var(--green) !important;
color: var(--ink) !important;
transform: scale(1.03) !important;
}
.task-nav-btn:active { transform: scale(0.97) !important; }
.task-position {
color: var(--muted) !important;
font-size: 0.85rem !important;
text-align: center !important;
}
/* ── Voice journal accordion ── */
.voice-journal-accordion {
border: 1px solid var(--paper-2) !important;
border-radius: 8px !important;
margin-bottom: 8px !important;
}
.voice-journal-accordion .label-wrap {
color: var(--muted) !important;
font-size: 0.9rem !important;
}
/* ── Hints vs safety flags ── */
.hint-item {
border-left: 3px solid var(--yellow) !important; /* amber = discovery */
padding-left: 10px !important;
margin: 6px 0 !important;
color: var(--muted) !important;
font-size: 0.9rem !important;
}
.safety-item {
border-left: 3px solid var(--red) !important; /* red = warning */
padding-left: 10px !important;
margin: 6px 0 !important;
color: #FFCDD2 !important;
font-size: 0.9rem !important;
background-color: rgba(244, 67, 54, 0.05) !important;
}
/* ── Completion unlock instruction ── */
.completion-instruction {
text-align: center !important;
font-size: 0.9rem !important;
padding: 6px 12px !important;
border-radius: 8px !important;
margin: 6px 0 !important;
font-weight: 500 !important;
}
/* Locked state reads as an action gate: a dashed callout box, not a flat
dim rectangle, with the lock icon sitting left of the text. */
.completion-instruction.locked {
display: flex !important;
align-items: center !important;
justify-content: center !important;
gap: 10px !important;
text-align: left !important;
border: 1.5px dashed rgba(255,255,255,0.18) !important;
border-radius: 8px !important;
padding: 8px 14px !important;
color: rgba(255,255,255,0.55) !important;
font-size: 0.82rem !important;
background: rgba(255,255,255,0.03) !important;
}
.completion-instruction.unlocked {
color: var(--green) !important;
background-color: rgba(76, 175, 80, 0.08) !important;
border: 1px solid rgba(76, 175, 80, 0.2) !important;
}
/* ── Bottom action buttons: Mark completed + Finish are pixel-identical ──
Both share one rule set targeting the inner ; the wrapper blocks
are stripped of padding so neither inherits stray pill-shape/spacing. */
.complete-btn, .finish-btn,
.complete-btn > div, .finish-btn > div,
.mark-complete-btn { padding: 0 !important; width: 100% !important; }
.complete-btn button,
.finish-btn button {
height: 52px !important;
min-height: 52px !important;
font-size: 1rem !important;
font-weight: 700 !important;
border-radius: 8px !important;
width: 100% !important;
padding: 0 24px !important;
transition: filter 0.2s ease !important;
}
/* Solid green for: Finish (always) + Mark completed when unlocked. */
.finish-btn button,
.complete-btn button:not(:disabled) {
background: #22c55e !important;
background-color: #22c55e !important;
color: #000 !important;
border: none !important;
cursor: pointer !important;
}
.finish-btn button:hover,
.complete-btn button:not(:disabled):hover {
filter: brightness(1.08) !important;
transform: none !important;
}
/* Mark completed — locked/disabled (outlined ghost). */
.complete-btn button:disabled,
.complete-btn button[disabled] {
background: transparent !important;
background-color: transparent !important;
border: 1.5px solid rgba(255, 255, 255, 0.15) !important;
color: rgba(255, 255, 255, 0.3) !important;
cursor: not-allowed !important;
}
/* Lobby "Start the adventure" — disabled while waiting for teams.
Ghost-disabled treatment so it reads as locked, not invisible. */
#lobby-start button:disabled,
#lobby-start button[disabled] {
background: transparent !important;
background-color: transparent !important;
border: 1.5px solid rgba(255, 255, 255, 0.15) !important;
color: rgba(255, 255, 255, 0.3) !important;
cursor: not-allowed !important;
}
/* Host "Start anyway" override — green underlined text link, not a button.
Gradio 6 puts elem_id/elem_classes on the itself, so style the
element directly. Also cover a descendant button + the class, so this holds
whether or not Gradio wraps the control. */
#lobby-start-anyway,
#lobby-start-anyway button,
.lobby-start-link,
.lobby-start-link button {
background: transparent !important;
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
width: auto !important;
min-width: 0 !important;
padding: 2px 4px !important;
margin: 6px auto 0 !important;
display: block !important;
color: var(--green) !important;
font-weight: 600 !important;
text-decoration: underline !important;
text-underline-offset: 3px !important;
cursor: pointer !important;
}
#lobby-start-anyway:hover,
#lobby-start-anyway button:hover,
.lobby-start-link:hover,
.lobby-start-link button:hover {
filter: brightness(1.15) !important;
transform: none !important;
background: transparent !important;
}
#lobby-start-anyway:disabled,
#lobby-start-anyway[disabled],
#lobby-start-anyway button:disabled,
#lobby-start-anyway button[disabled],
.lobby-start-link:disabled,
.lobby-start-link button:disabled,
.lobby-start-link button[disabled] {
color: rgba(255, 255, 255, 0.3) !important;
text-decoration: underline !important;
cursor: not-allowed !important;
}
/* ── Audio widget: let the "no microphone detected" message wrap ── */
.cq-audio .empty, .cq-audio .source-selection, .cq-audio span, .cq-audio p {
white-space: normal !important;
overflow: visible !important;
text-overflow: clip !important;
}
/* ── Proof-section button hierarchy ── */
/* Primary actions (Save journal, Add photo) — green/filled */
.btn-primary-action {
background-color: var(--green) !important;
color: #FFFFFF !important;
font-weight: 700 !important;
border: none !important;
transition: all 0.2s ease !important;
}
.btn-primary-action:hover {
background-color: var(--green-h) !important;
filter: brightness(1.1) !important;
transform: scale(1.02) !important;
}
/* Secondary actions (Transcribe) — grey/outlined */
.btn-secondary-action {
background-color: var(--paper-2) !important;
color: var(--muted) !important;
border: 1px solid var(--line) !important;
transition: all 0.2s ease !important;
}
.btn-secondary-action:hover {
border-color: var(--green) !important;
color: #FFFFFF !important;
transform: scale(1.02) !important;
}
/* ── Proof columns: voice journal & photos read as matching side-by-side
panels (same surface, padding, and header) ── */
.proof-col {
background: var(--paper) !important;
border: 1px solid var(--line) !important;
border-radius: 12px !important;
padding: 18px !important;
}
/* Strip Gradio's grey block + label-header fill behind the textbox labels
(Caption / Your notes). The proof-col already supplies the surface, so the
textarea's own dark fill + rounded border is all we want — no grey wrapper
rectangle and no slate label bar. Scoped to .proof-col so nothing else
changes. CSS-var override neutralizes the fills at the source; the explicit
selectors are the fallback. The textarea/input is never targeted, so its
own rule (dark fill + border) is preserved. */
.proof-col {
--block-label-background-fill: transparent !important;
--block-background-fill: transparent !important;
--block-title-background-fill: transparent !important;
}
/* 1. Strip the wrapper rectangle — block layers around the textbox. */
.proof-col .block,
.proof-col .form,
.proof-col [class*="block"]:not(.proof-col) {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
/* 2. Kill the slate label bar behind YOUR NOTES / CAPTION (keep the
uppercase label text styling defined for label span / .label-wrap span). */
.proof-col .label-wrap,
.proof-col label {
background: transparent !important;
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
padding-left: 0 !important;
}
/* Force both panels to equal height, anchored at top. The row's height is set
by the taller panel (Photos); stretch makes the shorter one (Voice) fill it. */
.panels-row { align-items: stretch !important; gap: 16px !important; }
.panels-row > div {
display: flex !important;
flex-direction: column !important;
height: 100% !important;
}
/* Each panel's inner Gradio Group/Column must also stretch. */
.panels-row .gr-group,
.panels-row .gr-column,
.panels-row [class*="block"] {
height: 100% !important;
display: flex !important;
flex-direction: column !important;
}
/* Keep the two panel headers on the same baseline. */
.panel-header { margin: 0 0 10px !important; }
/* ── In-game guide (hints) ── */
/* Header row: "Need a clue?" on the left, points badge on the right. */
.hints-header {
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
margin: 14px 0 8px !important;
gap: 12px !important;
flex-wrap: nowrap !important;
}
.hints-header > * { flex: 0 0 auto !important; }
.guide-header {
font-size: 0.95rem !important;
font-weight: 600 !important;
color: #FFFFFF !important; /* white — primary prompt */
margin: 0 !important;
}
.guide-header p { color: #FFFFFF !important; margin: 0 !important; }
/* Larger, weightier amber hint prompt. */
.hints-label,
.hints-label p {
font-size: 1.05rem !important;
font-weight: 600 !important;
color: #f5a623 !important;
margin-bottom: 10px !important;
}
.points-display, .points-display p {
font-size: 0.85rem !important;
font-weight: 600 !important;
color: var(--yellow) !important;
text-align: right !important;
margin: 0 !important;
}
/* Input + button on one line, equal height, vertically centred. */
.hints-input-row {
align-items: center !important;
gap: 8px !important;
}
/* Hint input: a clean rounded box (transparent fill, subtle border). */
.hint-input input,
.hint-input textarea {
background: transparent !important;
background-color: transparent !important;
border: 1.5px solid rgba(255, 255, 255, 0.2) !important;
border-radius: 10px !important;
box-shadow: none !important;
padding: 10px 14px !important;
color: rgba(255, 255, 255, 0.85) !important;
}
.hint-input input:focus,
.hint-input textarea:focus {
border: 1.5px solid #f5a623 !important;
outline: none !important;
box-shadow: none !important;
}
/* Strip every Gradio wrapper layer (block, form, container, wrap) so only the
underline shows — the input had a rounded box AND a rectangle behind it. */
.hint-input,
.hint-input *:not(input):not(textarea) {
background: transparent !important;
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
}
.hint-input,
.hint-input .block,
.hint-input .form,
.hint-input > div {
padding: 0 !important;
}
.hints-input-row input {
height: 44px !important;
}
.hints-input-row button {
height: 44px !important;
white-space: nowrap !important;
}
.points-display.reduced, .points-display.reduced p {
color: #FF7043 !important; /* orange-red = cost warning */
}
.btn-hint {
background-color: var(--paper-2) !important;
color: var(--yellow) !important;
border: 1.5px solid var(--yellow) !important;
border-radius: 10px !important;
font-weight: 600 !important;
transition: all 0.2s ease !important;
}
.btn-hint:hover {
background-color: rgba(255, 193, 7, 0.1) !important;
transform: scale(1.02) !important;
}
.btn-hint.spent {
color: #666666 !important;
border-color: var(--line) !important;
cursor: not-allowed !important;
opacity: 0.6 !important;
}
/* ── Play screen outer card ─────────────────────────────── */
.play-card {
background: rgba(42, 42, 42, 0.92) !important;
border: 1px solid var(--line) !important;
border-radius: 16px !important;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04),
0 8px 32px rgba(0, 0, 0, 0.45) !important;
padding: 20px !important;
overflow: hidden !important;
}
/* Strip Gradio's own block chrome so we don't stack borders/backgrounds */
.play-card.gr-group,
.play-card .block:first-child {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
/* Clip the task tab bar to the card's rounded top corners */
.play-card .task-tab-row {
border-radius: 12px 12px 0 0 !important;
}
/* Subtle separators between the major sections */
.play-card .panels-row {
border-top: 1px solid rgba(255, 255, 255, 0.06) !important;
padding-top: 16px !important;
margin-top: 12px !important;
}
.play-card .hints-header {
border-top: 1px solid rgba(255, 255, 255, 0.06) !important;
padding-top: 14px !important;
margin-top: 12px !important;
}
"""
JS_INIT = """
function() {
// ── Force DARK theme so every visitor sees identical colours ──
// Without this, Gradio follows each user's OS dark/light preference,
// so text/background regions look different per person.
try {
const u = new URL(window.location.href);
if (u.searchParams.get('__theme') !== 'dark') {
u.searchParams.set('__theme', 'dark');
window.location.replace(u.toString());
return;
}
} catch (e) {}
document.documentElement.classList.add('dark');
document.body && document.body.classList.add('dark');
const TIPS = __TOOLTIPS__;
function applyTips() {
for (const [id, tip] of Object.entries(TIPS)) {
const el = document.getElementById(id);
if (!el) continue;
el.setAttribute('data-tip', tip);
el.setAttribute('title', tip); // native browser tooltip fallback
const btn = el.querySelector('button');
if (btn) { btn.setAttribute('data-tip', tip); btn.setAttribute('title', tip); }
}
// keep the dark class on in case Gradio strips it
document.documentElement.classList.add('dark');
}
function ripple(e) {
const btn = e.target.closest('button');
if (!btn) return;
const r = btn.getBoundingClientRect();
const c = document.createElement('span');
c.className = 'cq-ripple';
const size = Math.max(r.width, r.height);
c.style.width = c.style.height = size + 'px';
c.style.left = (e.clientX - r.left - size / 2) + 'px';
c.style.top = (e.clientY - r.top - size / 2) + 'px';
btn.appendChild(c);
setTimeout(() => c.remove(), 600);
}
document.addEventListener('pointerdown', ripple, true);
applyTips();
// Gradio re-renders fragments on poll; keep tips fresh.
setInterval(applyTips, 700);
}
""".replace("__TOOLTIPS__", __import__("json").dumps(TOOLTIPS))
def gradio_theme():
"""Base Gradio theme tuned to the high-contrast dark palette.
Most styling lives in CSS; this only sets the fallback colours Gradio
paints before/around the stylesheet so there is no light flash.
"""
return gr.themes.Base(
primary_hue=gr.themes.colors.green,
secondary_hue=gr.themes.colors.gray,
neutral_hue=gr.themes.colors.gray,
font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
).set(
body_background_fill="#1A1A1A",
body_background_fill_dark="#1A1A1A",
body_text_color="#FFFFFF",
body_text_color_dark="#FFFFFF",
block_background_fill="rgba(42,42,42,0.92)",
block_background_fill_dark="rgba(42,42,42,0.92)",
block_border_color="#4A4A4A",
block_radius="18px",
button_large_radius="999px",
button_small_radius="999px",
)