File size: 39,722 Bytes
f48f055 8cd14fd f48f055 8cd14fd f48f055 8cd14fd f48f055 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd f48f055 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd f48f055 8cd14fd f48f055 8cd14fd f48f055 8cd14fd f48f055 8cd14fd f48f055 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 9e14c54 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a ec7a398 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 9e14c54 3cef34a 00d130f 9e14c54 3cef34a 9e14c54 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a 8cd14fd 9e14c54 8cd14fd 3cef34a 9e14c54 3cef34a 8cd14fd 3cef34a 8cd14fd 3cef34a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 | 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">· 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 · {board['width_in']}in · {board['thickness_in']}in · {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 · {spec['width_in']}in · {spec['thickness_in']}in · {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() |