Download app.py from Yoad22/surfmatch: direct link, hf CLI and curl.
- Browser
- Download file 39.7 kB
-
https://huggingface.co/spaces/Yoad22/surfmatch/resolve/main/app.py
- Command line
-
hf download hf://spaces/Yoad22/surfmatch/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Yoad22/surfmatch/resolve/main/app.py
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 | |
| 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() |