surfmatch / app.py
Yoad22's picture
Update app.py
00d130f verified
Raw History Blame Contribute Delete
39.7 kB
import spaces # kept only so HF's ZeroGPU hardware tier passes its startup check (see
# _zerogpu_keepalive below) - actual generation no longer needs a GPU at all.
import ast
import json
import os
import re
import gradio as gr
import numpy as np
import pandas as pd
from huggingface_hub import InferenceClient
from sentence_transformers import SentenceTransformer
# ── Constants ─────────────────────────────────────────────────────────────────
DATASET_REPO = "Yoad22/surfmatch-dataset"
EMBED_MODEL_NAME = "sentence-transformers/all-mpnet-base-v2"
GEN_MODEL_NAME = "Qwen/Qwen3-1.7B"
GEN_PROVIDER = "featherless-ai" # confirmed on the model's Hub page as an Inference Provider
CONDITIONS = ["small mushy beach breaks", "medium clean point breaks",
"powerful reef breaks", "big open-ocean swell"]
NO_BOARD_YET = "I don't have a board yet"
CURRENT_STYLE = [NO_BOARD_YET, "longboard cruiser", "all-around funboard", "fish / groveler",
"performance shortboard", "step-up / big-wave gun"]
GOAL_OPTIONS = {
"I'm brand new to surfing and want my first board": ("first_time_beginner", "get my very first surfboard and start learning to surf"),
"Get more comfortable on the board I already ride": ("stay_and_improve", "get more comfortable and consistent on the board I already ride"),
"Move toward something shorter and more maneuverable": ("transition_to_shortboard", "move from a longer, more stable board toward something shorter and more maneuverable"),
"Move toward a longer, more stable board": ("transition_to_longboard", "move toward a longer, more stable board for easier paddling and glide"),
"Get more stability and easier paddling": ("want_more_stability", "get more stability and easier paddling without changing style drastically"),
"Get a faster, more responsive board for sharper turns": ("want_more_speed_and_response", "get a faster, more responsive board for sharper turns"),
"Ride a board that can handle bigger, more powerful waves": ("want_bigger_wave_capability", "ride a board that can handle bigger, more powerful waves"),
"Get an easier board to paddle and catch waves on": ("want_easier_paddling", "get an easier board to paddle and catch waves on"),
}
STYLE_FOR_PROMPT = {
"longboard cruiser": "longboard cruiser",
"all-around funboard": "all-around funboard",
"fish / groveler": "fish/groveler-style board",
"performance shortboard": "performance shortboard",
"step-up / big-wave gun": "step-up gun",
}
def style_clause(current_style):
"""A grammatical clause describing the rider's current board, handling the
'no board yet' beginner case separately since it isn't a real board style."""
if current_style == NO_BOARD_YET:
return "doesn't have a board yet - this will be their first one"
return f"currently rides a {STYLE_FOR_PROMPT.get(current_style, current_style)}"
# Same skill -> experience-label ranges used to generate rider_profiles.csv in Part 1,
# used here only to turn the UI's single skill slider into an "experience" word for the
# Part 4 prompt, which expects one (the real notebook's riders already carry both fields).
SKILL_RANGES = {"beginner": (1, 22), "novice": (15, 40), "intermediate": (32, 65),
"advanced": (55, 88), "expert": (78, 100)}
def experience_for_skill(skill_score):
for label, (lo, hi) in SKILL_RANGES.items():
if lo <= skill_score <= hi:
return label
return "intermediate"
# ── Load data + models at startup ────────────────────────────────────────────
print("Loading data from the Hugging Face dataset repo...")
riders_df = pd.read_csv(f"hf://datasets/{DATASET_REPO}/rider_profiles.csv")
boards_df = pd.read_csv(f"hf://datasets/{DATASET_REPO}/board_catalog.csv")
boards_df = boards_df.reset_index(drop=True)
print("Loading the embedding model...")
embed_model = SentenceTransformer(EMBED_MODEL_NAME)
print("Loading precomputed board embeddings...")
board_emb_df = pd.read_parquet("board_embeddings.parquet")
boards_df = boards_df.merge(board_emb_df[["board_id", "embedding"]], on="board_id", how="left")
if boards_df["embedding"].isna().any():
raise RuntimeError("board_embeddings.parquet is missing rows for some board_id values.")
BOARD_EMBS = np.array(boards_df["embedding"].tolist(), dtype=np.float32)
print("Setting up the generation client (runs on Hugging Face's hosted Inference API, not on this Space)...")
HF_TOKEN = os.environ.get("HF_TOKEN") # set as a Space secret in Settings > Variables and secrets
gen_client = InferenceClient(provider=GEN_PROVIDER, api_key=HF_TOKEN)
print(f"Ready - {len(boards_df)} boards, {len(riders_df):,} rider profiles used for normalization bounds.")
# ── Normalization bounds + per-category ranges, from the real data ──────────
W_LO, W_HI = riders_df["weight_kg"].quantile([0.01, 0.99])
H_LO, H_HI = riders_df["height_cm"].quantile([0.01, 0.99])
SKILL_LO, SKILL_HI = riders_df["skill_score"].min(), riders_df["skill_score"].max()
# Same column names/rounding as the notebook's Part 4 (cell 127), so this Space's
# generation behaves identically to what was tested there.
CATEGORY_RANGES = boards_df.groupby("category").agg(
len_lo=("length_ft", "min"), len_hi=("length_ft", "max"),
width_lo=("width_in", "min"), width_hi=("width_in", "max"),
thick_lo=("thickness_in", "min"), thick_hi=("thickness_in", "max"),
vol_lo=("volume_l", "min"), vol_hi=("volume_l", "max"),
).round(1)
# ── Part 3: Recommendation with Embeddings (same logic as the notebook) ─────
def compute_style_fit(goal_key, skill_score, current_style):
"""Same rule used to build style_fit_truth in Part 1 and to pick a category in Part 3/4."""
if goal_key == "first_time_beginner":
return "longboard cruiser" # the classic, most forgiving starting point
if goal_key == "transition_to_shortboard":
return "performance shortboard" if skill_score >= 55 else "fish / groveler"
if goal_key == "transition_to_longboard":
return "longboard cruiser"
if goal_key == "want_bigger_wave_capability":
return "step-up / big-wave gun"
if goal_key == "want_more_speed_and_response":
return "performance shortboard" if skill_score >= 60 else "fish / groveler"
if goal_key in ("want_easier_paddling", "want_more_stability"):
return "longboard cruiser" if skill_score < 40 else "all-around funboard"
# Fallback: stick with the rider's current style - unless they don't have one yet,
# in which case NO_BOARD_YET isn't a real category, so default to the safest starter.
return current_style if current_style != NO_BOARD_YET else "longboard cruiser"
def pct(value, lo, hi):
if hi == lo:
return 0.5
return float(np.clip((value - lo) / (hi - lo), 0, 1))
def within_category_pct(series):
lo, hi = series.min(), series.max()
if hi == lo:
return pd.Series(0.5, index=series.index)
return (series - lo) / (hi - lo)
def pick_diverse(candidates, score_col, k=3):
picked, used_tails, used_fins, used_constr = [], set(), set(), set()
remaining = list(candidates.index)
for _ in range(k):
if not remaining:
break
best_idx, best_novelty, best_score = None, -1, -1
for idx in remaining:
board = candidates.loc[idx]
if not picked:
novelty = 3
else:
novelty = (int(board["tail"] not in used_tails)
+ int(board["fins"] not in used_fins)
+ int(board["construction"] not in used_constr))
score = board[score_col]
if (novelty, score) > (best_novelty, best_score):
best_idx, best_novelty, best_score = idx, novelty, score
board = candidates.loc[best_idx]
picked.append(board)
used_tails.add(board["tail"]); used_fins.add(board["fins"]); used_constr.add(board["construction"])
remaining.remove(best_idx)
return pd.DataFrame(picked)
def build_query_text(height_cm, weight_kg, skill_score, current_style, conditions, goal_text, extra_notes=""):
"""Mirrors the phrasing of the real profile_text used to train/evaluate the embeddings,
so a user's form inputs land in the same region of embedding space as real riders."""
text = (
f"I'm {height_cm}cm tall and {weight_kg}kg, with a skill level of {skill_score}/100. "
f"I {style_clause(current_style)} and surf {conditions}, and I want to {goal_text}."
)
if extra_notes and extra_notes.strip():
text += f" {extra_notes.strip()}"
return text
# App-only enhancement, layered on top of the notebook's evaluated category-first algorithm
# (Part 3, cell ~ the recommend_boards definition): rather than hard-filtering to only the
# rule-based target category, every board is scored, with a bonus for matching the target
# category. Most results still come from that category (the bonus is large), but a board
# from a neighboring category can occasionally surface if its fit/similarity is strong enough
# - this is intentionally "sometimes", not a forced quota, per real user feedback that seeing
# 3-4 near-identical boards felt repetitive. The notebook's own Part 3 evaluation (hit_rate,
# diversity-ceiling stats in the README) was run against the strict same-category version and
# is unaffected by this - it documents the core algorithm, this is a UX layer on top of it.
CATEGORY_BONUS = 0.18
def recommend_boards(height_cm, weight_kg, skill_score, current_style, conditions, goal_key, goal_text,
extra_notes="", k=3):
target_category = compute_style_fit(goal_key, skill_score, current_style)
weight_pct = pct(weight_kg, W_LO, W_HI)
height_pct = pct(height_cm, H_LO, H_HI)
forgiveness_pct = 1 - pct(skill_score, SKILL_LO, SKILL_HI)
volume_need = 0.5 * weight_pct + 0.5 * forgiveness_pct
length_need = 0.5 * height_pct + 0.5 * forgiveness_pct
candidates = boards_df.copy()
cand_embs = BOARD_EMBS[candidates.index.values]
query_text = build_query_text(height_cm, weight_kg, skill_score, current_style, conditions,
goal_text, extra_notes)
query_emb = embed_model.encode([query_text], normalize_embeddings=True)[0]
candidates["similarity"] = cand_embs @ query_emb
# Physical-fit percentiles computed within each board's OWN category (not just the target
# one), so boards from every category are on the same comparable 0-1 fit scale.
vol_pct = candidates.groupby("category")["volume_l"].transform(within_category_pct)
len_pct = candidates.groupby("category")["length_ft"].transform(within_category_pct)
candidates["volume_score"] = 1 - (volume_need - vol_pct).abs()
candidates["length_score"] = 1 - (length_need - len_pct).abs()
candidates["physical_fit"] = 0.5 * candidates["volume_score"] + 0.5 * candidates["length_score"]
candidates["category_match"] = (candidates["category"] == target_category).astype(float)
candidates["combined_score"] = (
0.5 * candidates["similarity"] + 0.5 * candidates["physical_fit"]
+ CATEGORY_BONUS * candidates["category_match"]
)
return target_category, pick_diverse(candidates, "combined_score", k=k)
# ── Part 4: Generation - custom board spec + rationale (finalized version) ──
# Grounds the prompt with real per-category ranges and each rider's weight/height/skill
# given as percentiles, and forces a "sizing_reasoning" field to be written before any
# numeric field (reasoning-first). Testing at scale showed this reasoning is honest but
# not fully faithful to the model's own numeric output for weight/height (see the Space
# README for the full, honestly-documented finding) - skill-based sizing works reliably,
# weight/height-based sizing is directionally present but weak. A fallback below keeps
# the app usable on the ~10% of calls that don't parse.
def build_custom_board_prompt(rider, target_category):
"""Adapted from the project notebook's Part 4 (cell 127) - the exact prompt behind the
documented 90% parse rate and the correlation numbers in the Space README, plus an
optional free-text line from the app's UI (not present in the notebook version)."""
r = CATEGORY_RANGES.loc[target_category]
weight_pct = pct(rider["weight_kg"], W_LO, W_HI)
height_pct = pct(rider["height_cm"], H_LO, H_HI)
skill_pct = pct(rider["skill_score"], SKILL_LO, SKILL_HI)
facts = (
f"Surfer facts - height {rider['height_cm']}cm, weight {rider['weight_kg']}kg, "
f"{rider['experience']} (skill {rider['skill_score']}/100), {style_clause(rider['current_style'])}, "
f"surfs {rider['conditions']}, wants to: {rider['goal_text']}. "
f"Closest matching standard category: {target_category}."
)
if rider.get("extra_notes") and rider["extra_notes"].strip():
facts += f" In their own words, they also said: \"{rider['extra_notes'].strip()}\""
percentile_hint = (
f"Relative to other surfers in our data, this person is heavier than about {weight_pct:.0%} "
f"of them, taller than about {height_pct:.0%} of them, and more skilled than about "
f"{skill_pct:.0%} of them."
)
sizing_guidance = (
f"Boards in the {target_category} category on this platform typically run "
f"{r['len_lo']}-{r['len_hi']} feet long, {r['width_lo']}-{r['width_hi']} inches wide, "
f"{r['thick_lo']}-{r['thick_hi']} inches thick, and {r['vol_lo']}-{r['vol_hi']} liters. "
"A surfer who is heavier or less skilled than average needs more volume and a wider, "
"thicker board. A surfer who is taller or less skilled than average wants relatively "
"more length."
)
instruction = (
"You are a surfboard shaper designing ONE custom, one-of-a-kind board for this surfer. "
"This board does not have to follow typical conventions for its category - make an "
"unconventional choice (for example, an unusual fin setup) if you genuinely think it "
"suits this surfer better than the standard options would.\n\n"
f"{facts}\n\n{percentile_hint}\n\n{sizing_guidance}\n\n"
"Fin setups include single fin, twin fin, thruster (3 fin), quad (4 fin), and 2+1 setup. "
"Tail shapes include round, square, squash, pin, and swallow. Construction options are "
"PU/polyester, EPS/epoxy, or soft-top.\n\n"
"Respond with ONLY a JSON object in this exact format, no other text. Fill in "
"sizing_reasoning FIRST (keep it to ONE sentence, under 30 words), working through this "
"surfer's weight percentile, height percentile, and skill percentile explicitly and how "
"each pushes the numbers bigger or smaller, before deciding on the actual dimensions. "
"Keep rationale to one short sentence too, under 30 words:\n"
'{"sizing_reasoning": "<one sentence, under 30 words, reasoning through weight/height/skill '
'percentiles and how each affects size>", "length_ft": <number>, "width_in": <number>, '
'"thickness_in": <number>, "volume_l": <number>, "tail": "<tail shape>", '
'"fins": "<fin setup>", "construction": "<construction type>", "rationale": "<one short '
'sentence, under 30 words, on why this board, especially anything unconventional, suits '
'this specific surfer>"}'
)
return instruction
def parse_custom_board_response(text):
"""Verbatim from the notebook's Part 4 (cell 127)."""
m = re.search(r"\{.*\}", text, re.DOTALL)
if not m:
return None
try:
data = json.loads(m.group())
except json.JSONDecodeError:
return None
required = {"sizing_reasoning", "length_ft", "width_in", "thickness_in", "volume_l",
"tail", "fins", "construction", "rationale"}
if not required.issubset(data.keys()):
return None
try:
for field in ["length_ft", "width_in", "thickness_in", "volume_l"]:
data[field] = float(data[field])
except (TypeError, ValueError):
return None
return data
def fallback_custom_board(target_category):
"""Used on the minority of calls where neither attempt below parses, so the app always
returns something usable rather than an error."""
r = CATEGORY_RANGES.loc[target_category]
cat_boards = boards_df[boards_df["category"] == target_category]
spec = {
"sizing_reasoning": "",
"length_ft": round((r["len_lo"] + r["len_hi"]) / 2, 1),
"width_in": round((r["width_lo"] + r["width_hi"]) / 2, 2),
"thickness_in": round((r["thick_lo"] + r["thick_hi"]) / 2, 2),
"volume_l": round((r["vol_lo"] + r["vol_hi"]) / 2, 1),
"tail": cat_boards["tail"].mode().iat[0],
"fins": cat_boards["fins"].mode().iat[0],
"construction": cat_boards["construction"].mode().iat[0],
"rationale": "A solidly built, category-typical board while our AI designer catches up - "
"click Find My Boards again for a more personalized custom spec.",
}
spec["_fallback"] = True
return spec
@spaces.GPU(duration=5)
def _zerogpu_keepalive():
"""Never actually called - it exists only so Hugging Face's ZeroGPU hardware tier (the
only free option available when this Space was created) passes its startup check, which
requires at least one @spaces.GPU-decorated function to be present in the app. Real
generation below goes through Hugging Face's hosted Inference API instead, which runs on
that provider's own infrastructure and never touches this Space's GPU allocation - this
is what actually avoids the ZeroGPU worker crash, not this function."""
return True
def _run_generation(prompt):
"""Calls Hugging Face's hosted Inference API (via the Featherless AI provider, confirmed
to serve this model on its Hub page) so generation runs on that provider's own servers,
not on this Space - the ZeroGPU worker-allocation problem never gets a chance to happen."""
completion = gen_client.chat_completion(
messages=[{"role": "user", "content": prompt}],
model=GEN_MODEL_NAME,
max_tokens=400,
temperature=0.8,
)
return completion.choices[0].message.content
def generate_custom_board(rider, target_category, max_attempts=2):
"""Verbatim from the notebook's Part 4 (cell 127), plus the app-only fallback for when
both attempts fail to parse (or the API call itself fails/times out/rate-limits)."""
prompt = build_custom_board_prompt(rider, target_category)
for _ in range(max_attempts):
try:
raw_text = _run_generation(prompt)
except Exception as e:
print(f"Generation API call failed: {e}")
continue
spec = parse_custom_board_response(raw_text)
if spec is not None:
spec["_fallback"] = False
return spec
return fallback_custom_board(target_category)
# ── UI rendering ──────────────────────────────────────────────────────────────
def score_badge(display_pct):
"""display_pct is already rescaled (see rescale_display_scores) to a range calibrated
against the boards actually being shown, so it always reads as a meaningful comparison
rather than an intimidatingly low absolute number. Rendered as a rubber-stamp badge -
a rotated circle with the percentage and a short tier word (no bare unqualified low
number, and no repeated "Match" filling up the small stamp)."""
if display_pct >= 85:
cls, label = "stamp-great", "GREAT"
elif display_pct >= 70:
cls, label = "stamp-good", "GOOD"
else:
cls, label = "stamp-solid", "SOLID"
return f'<div class="board-stamp {cls}">{display_pct:.0f}%<br>{label}</div>'
def rescale_display_scores(scores):
"""Rescales a set of raw combined_score values (which have no inherent 0-100 'quality'
meaning on their own) into a 60-98% band relative to the boards actually being shown,
so the badge always communicates 'how this one compares to your other results' rather
than an absolute score that can look unflatteringly low for an otherwise solid match."""
lo, hi = scores.min(), scores.max()
if hi == lo:
return pd.Series(82.0, index=scores.index)
return 60 + 38 * (scores - lo) / (hi - lo)
def clean_model_name(brand, model_name):
"""The catalog's model_name already includes the brand (e.g. 'Al Merrick Puddle Jumper'
for brand 'Al Merrick'), so showing both verbatim duplicated the brand - this strips it."""
if model_name.lower().startswith(brand.lower()):
return model_name[len(brand):].strip()
return model_name
def board_card(board, rank, display_pct, target_category):
"""A single die-cut, sticker-shaped board card (the "Board Rack" surf-shop poster look).
The category line is always shown, since it's the clearest way to flag the occasional
cross-category pick the recommender now allows (see CATEGORY_BONUS above) - when it
differs from the search's own target category, we mark it "outside category"."""
category_label = board['category'].title()
if board['category'] != target_category:
category_label += ' <span class="outside-tag">&middot; outside category</span>'
return f"""
<div class="board-card">
{score_badge(display_pct)}
<div class="board-card-rank">#{rank}</div>
<div class="board-card-brand">{board['brand']}</div>
<div class="board-card-title">{clean_model_name(board['brand'], board['model_name'])}</div>
<div class="board-card-category">{category_label}</div>
<div class="board-card-spec">
{board['length_ft']}ft &middot; {board['width_in']}in &middot; {board['thickness_in']}in &middot; {board['volume_l']}L
</div>
<div class="board-card-tags">
<span class="tag-chip">{board['tail']}</span>
<span class="tag-chip">{board['fins']}</span>
<span class="tag-chip">{board['construction']}</span>
</div>
</div>"""
def custom_card(spec, target_category):
if spec.get("_fallback"):
note = '<div class="fallback-note">Our AI designer had trouble this time, so here\'s a solid category-typical spec instead.</div>'
else:
note = f'<div class="rationale"><strong>Why:</strong> {spec.get("sizing_reasoning", "")}</div>'
return f"""
<div class="board-card custom-card">
<div class="board-stamp stamp-ai">AI<br>MADE</div>
<div class="board-card-brand">AI Generated</div>
<div class="board-card-title">Your Custom Board</div>
<div class="board-card-category">{target_category.title()}</div>
<div class="board-card-spec">
{spec['length_ft']}ft &middot; {spec['width_in']}in &middot; {spec['thickness_in']}in &middot; {spec['volume_l']}L
</div>
<div class="board-card-tags">
<span class="tag-chip">{spec['tail']}</span>
<span class="tag-chip">{spec['fins']}</span>
<span class="tag-chip">{spec['construction']}</span>
</div>
<div class="rationale">{spec['rationale']}</div>
{note}
</div>"""
def find_boards(height_cm, weight_kg, skill_score, goal_label, conditions, current_style, extra_notes=""):
goal_key, goal_text = GOAL_OPTIONS[goal_label]
target_category, recs = recommend_boards(
height_cm, weight_kg, skill_score, current_style, conditions, goal_key, goal_text, extra_notes,
)
rider = {
"height_cm": height_cm, "weight_kg": weight_kg, "skill_score": skill_score,
"experience": experience_for_skill(skill_score), "current_style": current_style,
"conditions": conditions, "goal_text": goal_text, "extra_notes": extra_notes,
}
custom = generate_custom_board(rider, target_category)
display_pcts = rescale_display_scores(recs["combined_score"])
cards_html = '<div class="results-head">RECOMMENDED: ' + target_category.upper() + '</div>'
cards_html += '<div class="cards-grid">'
for i, ((_, board), display_pct) in enumerate(zip(recs.iterrows(), display_pcts), 1):
cards_html += board_card(board, i, display_pct, target_category)
cards_html += custom_card(custom, target_category)
cards_html += '</div>'
return cards_html
QUICK_STARTERS = {
"Never surfed before": (163, 65, 5,
"I'm brand new to surfing and want my first board", "small mushy beach breaks", NO_BOARD_YET,
"I'm a little nervous about duck-diving under waves and want something easy and forgiving."),
"Beginner wanting stability": (168, 72, 15,
"Get more stability and easier paddling", "small mushy beach breaks", "performance shortboard", ""),
"Advanced wanting speed": (178, 75, 78,
"Get a faster, more responsive board for sharper turns", "powerful reef breaks", "all-around funboard", ""),
"Longboarder chasing big waves": (182, 85, 60,
"Ride a board that can handle bigger, more powerful waves", "big open-ocean swell", "longboard cruiser", ""),
}
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Alfa+Slab+One&family=Barlow:wght@400;500;600;700&display=swap');
:root {
--navy: #0d2b36;
--blue: #134a5e;
--yellow: #f2b705;
--coral: #ff6a3d;
--cream: #fdf6e3;
--rust: #c1440e;
--page-bg: #f3ead9;
--muted: #5a6f77;
}
/* overflow-x:hidden on both body and the app container is a belt-and-suspenders fix for
a stray horizontal scrollbar some browsers showed - nothing in this layout is meant to
scroll sideways, so clipping any accidental overflow is always the right call here. */
body { overflow-x: hidden !important; }
.gradio-container { background: var(--page-bg) !important; font-family: 'Barlow', sans-serif !important;
overflow-x: hidden !important; }
.gradio-container * { font-family: 'Barlow', sans-serif; }
/* ── The whole app sits on one big screen-printed poster sheet ────────────── */
.poster-shell {
position: relative;
overflow: hidden;
background: var(--blue);
border-radius: 20px;
padding: 28px 26px 34px;
margin-bottom: 4px;
}
/* !important on position/z-index guarantees these stay absolute + behind the real
content no matter what a later broad selector (like the one below, which lifts
every other direct child above them) does - Chromium paints a plain negative
z-index behind the shell's own opaque background, which hid these entirely, so
they're pinned to z-index:0 while real content is lifted to z-index:1 instead. */
.blob-1, .blob-2 { position: absolute !important; z-index: 0 !important; border-radius: 50%; pointer-events: none; }
.blob-1 { width: 300px; height: 300px; background: var(--yellow); top: -140px; right: -100px; opacity: 0.9; }
.blob-2 { width: 200px; height: 200px; background: var(--coral); bottom: -110px; left: -80px; opacity: 0.85; }
.poster-shell > * { position: relative; z-index: 1; }
/* Exception: the very first child is the gr.HTML block holding the two decorative blobs -
Gradio wraps that call in its own auto-generated ".prose" div, which has no real content
of its own (both its children are position:absolute). Left subject to the rule above, that
wrapper becomes a new positioning root for the blobs and collapses to near-zero height,
which drags both blobs into a thin sliver at the top of the page. Excluding just this
first child keeps it out of the flow entirely so the blobs stay positioned against
.poster-shell itself, where they were always meant to sit. */
.poster-shell > *:first-child { position: static !important; z-index: auto !important; }
.brand-row { display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; flex-wrap: wrap; margin-bottom: 18px; }
.brand-word {
font-family: 'Alfa Slab One', cursive; font-size: 34px; color: #fff;
text-shadow: 3px 3px 0 var(--navy); letter-spacing: 0.01em; line-height: 1;
}
.brand-tag {
background: #fff; color: var(--navy); font-weight: 600; font-size: 13px;
padding: 10px 14px; border-radius: 10px; border: 2.5px solid var(--navy);
max-width: 340px; line-height: 1.4;
}
.stat-pills { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; }
.stat-chip {
background: rgba(255,255,255,0.18); color: #fff; border: 1.5px solid rgba(255,255,255,0.5);
border-radius: 20px; padding: 4px 12px; font-size: 11.5px; font-weight: 600;
}
.qs-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.qs-btn {
background: #fff !important;
border: 2.5px solid var(--navy) !important;
border-radius: 20px !important;
color: var(--navy) !important;
font-weight: 700 !important;
font-size: 12.5px !important;
box-shadow: none !important;
transition: transform 0.1s ease, background 0.1s ease !important;
}
.qs-btn:hover { background: var(--yellow) !important; transform: translateY(-1px); }
/* ── Input panel: a thick-bordered card floating on the poster ────────────── */
.input-panel { background: #fff !important; border: 2.5px solid var(--navy) !important;
border-radius: 14px !important; padding: 16px !important; }
/* Gradio marks every component's own label span with data-testid="block-info" regardless
of component type (Slider/Textbox additionally wrap it in a <label>, Dropdown doesn't) -
targeting that attribute directly is what actually catches all of them consistently,
rather than relying on a <label> ancestor that Dropdown doesn't have. */
.input-panel label, .input-panel [data-testid="block-info"] { color: var(--navy) !important; font-weight: 700 !important;
font-size: 12px !important; text-transform: uppercase; letter-spacing: 0.04em; }
/* The line above assumes the panel's own background is showing through (confirmed correct
in every theme this was actually tested in) - but Hugging Face's embed loads the app at
`__theme=system`, and on a visitor whose system is in dark mode, Gradio adds a `dark`
class to an ancestor of the app and its own internal grouping wrapper around these fields
(independent of our `.input-panel` background rule above) then paints itself in dark
colors, leaving navy-on-dark just as unreadable as the original dark-on-dark bug. Keying
the label color off `.dark` (the same ancestor class Gradio's own built-in dark-theme
rules key off, confirmed by inspecting Gradio's own generated CSS) guarantees a readable
label in both cases without touching the light-theme styling already confirmed working.
Deliberately NOT written as "body.dark" - Gradio's CSS-injection pipeline rewrites any
custom rule containing the literal word "body" and mishandles a compound "body.dark"
selector, silently breaking it (confirmed by inspecting the actual injected stylesheet).
The repeated ".dark" below isn't a typo - Gradio's own CSS pipeline duplicates the rule
above with an auto-added ".gradio-container-5-9-1 .contain" prefix (confirmed by reading
the actual injected stylesheet), which raises ITS specificity, so a single ".dark" here
isn't enough to reliably win against it - repeating the class raises this rule's own
specificity comfortably past that, regardless of what Gradio's rewriter does to either
copy. */
.dark.dark.dark.dark.dark .input-panel label,
.dark.dark.dark.dark.dark .input-panel [data-testid="block-info"] {
color: #fff !important;
}
/* Explicit background AND text color on every form control - don't rely on Gradio's
theme (light vs dark) leaving these readable on its own, since that's exactly what
went wrong before: dark theme's own dark input background + our navy text color
stacked into invisible dark-on-dark. */
.input-panel input,
.input-panel textarea,
.input-panel select,
.input-panel [data-testid="textbox"] textarea,
.input-panel ul, .input-panel li {
font-family: 'Barlow', sans-serif !important;
background: #fff !important;
color: var(--navy) !important;
border: 1.5px solid #cfd8db !important;
border-radius: 8px !important;
}
.input-panel input::placeholder, .input-panel textarea::placeholder {
color: #8a97a0 !important; opacity: 1 !important;
}
.input-panel input[type="range"] { accent-color: var(--coral) !important; background: transparent !important; border: none !important; }
.input-panel input[type="number"] { border-radius: 8px !important; }
.welcome-block { margin: 4px 0 20px; }
.welcome-line { color: #fff; font-size: 15px; font-weight: 600; max-width: 460px; line-height: 1.55; }
.cta-btn { font-size: 17px !important; padding: 16px 20px !important; max-width: 300px; }
.find-btn {
background: var(--coral) !important; border: 2.5px solid var(--navy) !important;
border-radius: 12px !important; color: #fff !important; font-weight: 700 !important;
font-size: 15px !important; text-transform: uppercase; letter-spacing: 0.03em;
box-shadow: 4px 4px 0 rgba(13,43,54,0.5) !important;
}
.find-btn:hover { transform: translate(1px, 1px); box-shadow: 3px 3px 0 rgba(13,43,54,0.5) !important; }
/* ── Results ────────────────────────────────────────────────────────────────── */
.results-head {
font-family: 'Alfa Slab One', cursive; font-size: 16px; color: #fff;
text-shadow: 2px 2px 0 var(--navy); margin-bottom: 14px;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 18px;
}
/* Die-cut, sticker-shaped board card */
.board-card {
background: var(--cream);
border: 3px solid var(--navy);
border-radius: 4px 16px 4px 16px;
padding: 16px;
position: relative;
box-shadow: 5px 5px 0 rgba(13,43,54,0.35);
}
.board-card-rank { position: absolute; top: 10px; left: 14px; font-family: 'Alfa Slab One', cursive;
font-size: 12px; color: var(--muted); }
.board-stamp {
position: absolute; top: -16px; right: 10px;
border: 2.5px solid var(--navy); border-radius: 50%;
width: 54px; height: 54px; display: flex; align-items: center; justify-content: center;
flex-direction: column; text-align: center; line-height: 1.15;
font-weight: 700; font-size: 11px; color: #fff; transform: rotate(8deg);
}
.stamp-great { background: #1a7f37; }
.stamp-good { background: var(--coral); }
.stamp-solid { background: #b06e00; }
.stamp-ai { background: var(--navy); }
.board-card-brand { color: var(--rust); font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; margin: 16px 0 2px; }
.board-card-title { font-family: 'Alfa Slab One', cursive; font-size: 17px; color: var(--navy);
margin: 0 0 8px; line-height: 1.15; }
.board-card-category { font-size: 11.5px; font-weight: 700; color: var(--muted); text-transform: uppercase;
letter-spacing: 0.03em; margin-bottom: 9px; }
.outside-tag { color: var(--rust); font-weight: 600; text-transform: none; letter-spacing: 0; }
.board-card-spec { font-weight: 600; font-size: 12.5px; color: #3d5158; margin-bottom: 10px; }
.board-card-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 4px; }
.tag-chip { background: var(--navy); color: #fff; font-size: 10.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; }
.custom-card { background: #fff; border-style: dashed; }
.rationale { font-size: 12px; color: var(--muted); line-height: 1.5; font-weight: 500; margin-top: 8px; }
.fallback-note { font-size: 11px; color: var(--muted); margin-top: 6px; }
"""
BLOBS_HTML = '<div class="blob-1"></div><div class="blob-2"></div>'
HEADER_HTML = """
<div class="brand-row">
<div class="brand-word">SurfMatch</div>
<div class="brand-tag">Tell us about yourself and we'll match you with 3 real boards from
our catalog, plus one custom, AI-designed board shaped just for you.</div>
</div>
<div class="stat-pills">
<span class="stat-chip">150 boards</span>
<span class="stat-chip">15,000 rider profiles analyzed</span>
<span class="stat-chip">AI-powered</span>
</div>
"""
WELCOME_HTML = """
<div class="welcome-block">
<div class="welcome-line">Answer a few quick questions about your height, weight, and how
you like to surf, and we'll do the rest.</div>
</div>
"""
# Forces the light theme this app was designed around, regardless of the visitor's
# system/browser dark-mode setting - without this, Gradio's default dark theme bleeds
# through on the input panel and clashes with the custom CSS above.
FORCE_LIGHT_JS = """
function refresh() {
const url = new URL(window.location);
if (url.searchParams.get('__theme') !== 'light') {
url.searchParams.set('__theme', 'light');
window.location.href = url.href;
}
}
"""
with gr.Blocks(title="SurfMatch", css=CSS, js=FORCE_LIGHT_JS) as demo:
with gr.Column(elem_classes="poster-shell"):
gr.HTML(BLOBS_HTML)
gr.HTML(HEADER_HTML)
with gr.Column(visible=True) as welcome_section:
gr.HTML(WELCOME_HTML)
start_btn = gr.Button("Let's Find Your Board", elem_classes="find-btn cta-btn")
with gr.Column(visible=False) as form_section:
with gr.Row(elem_classes="qs-row"):
qs_buttons = [gr.Button(label, size="sm", elem_classes="qs-btn") for label in QUICK_STARTERS]
with gr.Row():
with gr.Column(scale=2, elem_classes="input-panel"):
height = gr.Slider(140, 210, value=175, step=1, label="Height (cm)")
weight = gr.Slider(40, 130, value=72, step=1, label="Weight (kg)")
skill = gr.Slider(1, 100, value=50, step=1, label="Skill level")
goal = gr.Dropdown(list(GOAL_OPTIONS.keys()), value=list(GOAL_OPTIONS.keys())[0], label="Your goal")
conditions = gr.Dropdown(CONDITIONS, value=CONDITIONS[0], label="Typical conditions")
current_style = gr.Dropdown(CURRENT_STYLE, value=CURRENT_STYLE[0], label="Current board")
extra_notes = gr.Textbox(
label="Anything else about you or the board you're picturing? (optional)",
placeholder="e.g. I want something colorful, I'm nervous about duck-diving, "
"I want a board that turns heads...",
lines=2,
)
submit = gr.Button("Find My Boards", elem_classes="find-btn")
with gr.Column(scale=3):
output = gr.HTML()
# "Let's Find Your Board" swaps the welcome screen out for the actual form, instead of
# showing every slider/dropdown up front.
start_btn.click(
lambda: (gr.update(visible=False), gr.update(visible=True)),
outputs=[welcome_section, form_section],
)
all_inputs = [height, weight, skill, goal, conditions, current_style, extra_notes]
submit.click(find_boards, inputs=all_inputs, outputs=output)
for label, (h, w, s, g, c, cs, notes) in QUICK_STARTERS.items():
idx = list(QUICK_STARTERS.keys()).index(label)
qs_buttons[idx].click(
lambda h=h, w=w, s=s, g=g, c=c, cs=cs, notes=notes: (h, w, s, g, c, cs, notes),
outputs=all_inputs,
).then(find_boards, inputs=all_inputs, outputs=output)
demo.launch()