blackboxanalytics commited on
Commit
a4c66c6
ยท
verified ยท
1 Parent(s): 6ee304f

Deploy Hackathon Roulette

Browse files
Files changed (3) hide show
  1. README.md +32 -6
  2. app.py +712 -0
  3. requirements.txt +2 -0
README.md CHANGED
@@ -1,13 +1,39 @@
1
  ---
2
  title: Hackathon Roulette
3
- emoji: ๐Ÿจ
4
- colorFrom: red
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Hackathon Roulette
3
+ emoji: ๐ŸŽฐ
4
+ colorFrom: purple
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 6.16.0
 
8
  app_file: app.py
9
  pinned: false
10
+ short_description: Spin to discover hidden gems from the Build Small Hackathon
11
  ---
12
 
13
+ # ๐ŸŽฐ Hackathon Roulette
14
+
15
+ Hundreds of projects ship during the **Build Small Hackathon** โ€” and most of
16
+ them never get seen because everyone scrolls the same first page. This is a
17
+ slot machine for the whole submission pool: pull the lever and get a random
18
+ project you haven't seen yet.
19
+
20
+ ## What it does
21
+
22
+ - **Indexes every submission** in the hackathon org, live from the Hub.
23
+ - **Auto-categorizes** each project into 12 topic buckets (games, voice,
24
+ health, dev tools, creative, agents, and more) so you can browse by theme.
25
+ - **Roulette** โ€” one button serves up a random project you haven't viewed,
26
+ remembering what you've already seen for the session (no database needed).
27
+ - **Browse mode** โ€” open any category and scan the most-liked projects.
28
+ - Each result shows the name, description, category, official track badge,
29
+ likes, and a direct link to the Space.
30
+
31
+ ## Run locally
32
+
33
+ ```bash
34
+ pip install -r requirements.txt
35
+ python app.py
36
+ ```
37
+
38
+ The app pulls submissions straight from the Hub at startup โ€” no API keys, no
39
+ configuration. Drop it on a Hugging Face Space and it just runs.
app.py ADDED
@@ -0,0 +1,712 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hackathon Roulette โ€” spin through the Build Small Hackathon and discover
3
+ projects you'd never find by scrolling. Categorizes every submission and
4
+ lets you roulette your way to hidden gems (with in-session memory of what
5
+ you've already seen).
6
+ """
7
+
8
+ import random
9
+ import re
10
+ import time
11
+ import html as _html
12
+
13
+ import gradio as gr
14
+ from huggingface_hub import HfApi
15
+
16
+ # โ”€โ”€ Config (the only hackathon this tool follows) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
17
+
18
+ HACKATHON_ORG = "build-small-hackathon"
19
+ HACKATHON_NAME = "Build Small Hackathon"
20
+
21
+ # Org spaces that aren't real submissions โ€” excluded from the roulette.
22
+ INFRA_SPACES = {"field-guide", "readme", "registration"}
23
+
24
+ # โ”€โ”€ Category taxonomy โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
25
+ # Ordered list of (name, emoji, keywords). Categorization is FIRST-MATCH-WINS,
26
+ # so order runs specific genres/domains first, modality next, and the broad
27
+ # "Assistants" bucket last as a catch-all. Keywords are matched with a LEFT
28
+ # word-boundary (\bkw) against a camelCase-split, lowercased blob of
29
+ # title+name+description+tags โ€” so "radio" never matches "gradio" and "voice"
30
+ # never matches "invoice", while stems like "transcrib" still catch
31
+ # "transcribe". Tuned against all 943 live submissions (~29% land in Other).
32
+
33
+ TAXONOMY = [
34
+ ("Games, RPGs & Interactive Fiction", "๐ŸŽฎ", ["game","rpg","roguelike","roguelite","deckbuild","dungeon","escape room","escape-room","text-adventure","text adventure","adventure","interactive-fiction","interactive fiction","visual novel","visual-novel","ttrpg","tabletop","whodunit","detective","courtroom","tribunal","verdict","jury","puzzle","riddle","arcade","poker","chess","mafia","duel","arena","akinator","backrooms","parkour","phaser","playable","gameplay","roleplay","role-play","reverse-turing","reverse turing","trolley","board game","deathmatch","choose your","trivia","survival","maze","wordle","sudoku","betting","dating sim","platformer","sokoban","tower defense","idle game","mystery","heist","casino","card game"]),
35
+ ("Worlds, Sims & Multi-Agent Societies", "๐ŸŒ", ["multi-agent","multi agent","simulation","simulator","persistent world","world-build","world building","world-building","world model","colony","civilization","civilisation","city-builder","city builder","village","smol town","ecosystem","terraform","self-play","agent-simulation","trading firm","rumor economy","gossip economy","tiny civilization","living forest","sandbox","life sim","tamagotchi","god game"]),
36
+ ("Health, Medical & Wellness", "๐Ÿฉบ", ["medical","clinical","clinic","healthcare","health companion","medgemma","patient","symptom","diagnos","radiology","x-ray","dental","pharmacy","pharma","prescription","medicine","blood test","nutrition","nutri","pregnan","caregiver","elderly","parkinson","stroke","vaccine","neoantigen","first aid","physiotherap","cardiac","heartbeat","mental-health","mental health","emotional support","anxiety","therapy","wellness","grief","adhd","neurodiverg","allergen"]),
37
+ ("Tutors, Study & Language Learning", "๐ŸŽ“", ["tutor","study buddy","study partner","studybuddy","study copilot","homework","exam","flashcard","quiz","socratic","lesson","teaching","classroom","kids","children","preschool","toddler","language-learning","language learning","language practice","lingo","learn korean","learn japanese","learn languages","duolingo","reading buddy","read-along","read along","dyslexi","iep","whiteboard","cbse","ncert","samacheer","scholar","literature-review","interview coach","interview prep","speech coach","running coach","gym coach","curriculum","educational","flashcard generator","spaced repetition","vocabulary","grammar","mnemonic"]),
38
+ ("Finance, Documents & Business", "๐Ÿ’ผ", ["invoice","accounting","bookkeep","ledger","receipt","expense","budget","taxes","tax filing","tax season","finops","finance","financial","stocks","stock market","trading","alpha signal","sec filing","insurance","crm","inventory","udhaar","shopkeeper","kirana","dukaan","sales","pricing","price comparison","real-estate","real estate","ads advisor","resume","job description","reconcil","lease","legal","legislation","contract","bureaucra","immigration","compliance","ocr","document-ai","text-to-sql","spreadsheet","rfq","payroll","procurement","gig work","freelance"]),
39
+ ("Scan, Vision & Physical-World Utilities", "๐Ÿ“ธ", ["plant","garden","crop","weed","herbicide","farm","beekeep","honeybee","apiar","soil","satellite","eurosat","land-use","solar","weather","watering","fridge","recipe","meal plan","cook","ingredient","food waste","upcycl","mushroom","forager","edible","stray animal","wardrobe","outfit","fashion","stylist","car diagnostic","appliance fault","repair","defect","building-inspection","road-defect","object-detection","yolo","vlm","vision-language","computer-vision","image classif","segment","label lens","posture","barcode","football","soccer","referee","sports"]),
40
+ ("Safety, Security & Privacy", "๐Ÿ›ก๏ธ", ["scam","phishing","fraud","online-safety","online safety","cybersec","security","soc analyst","osint","threat","vuln","pii","privacy","redact","guardrail","firewall","dark pattern","deepfake","captcha","jailbreak","safety-eval","prompt-injection","airlock"]),
41
+ ("Dev, Code, Robotics & Model Tooling", "โš™๏ธ", ["code review","code-review","code-generation","commit","repo","github","mermaid","flowchart","diagram","debug","rubber duck","rubber-duck","pull request","refactor","decompile","gpu kernel","gpu-kernel","fine-tune","fine-tuning","distillation","quantiz","benchmark","structured json","data-engineering","excalidraw","skill router","skill-router","formal-verification","lean4","mcp-server","devops","reachy","robot","esp32","arduino","onnx","webgpu","transformers.js","transformers-js","rl-environment","openenv","grpo","interpretab","surprisal","next-token","abliterat","tokenizer","regex","evaluation pipeline","llm eval","sql query"]),
42
+ ("Voice, Speech, Music & Translation", "๐ŸŽ™๏ธ", ["voice","music","radio","voice-clon","voice clon","tts","text-to-speech","asr","speech-to-text","speech-to-speech","speech-translation","whisper","voxcpm","kokoro","f5-tts","fastconformer","dubbing","narration","narrator","read aloud","read-aloud","audiobook","podcast","music-generation","music generator","lofi","lullab","singalong","karaoke","lyric","sheet music","transcrib","pronunciation","recitation","sign language","sign-language","sign2voice","dictation","translation","translate","spoken word"]),
43
+ ("Story, Image & Creative Generation", "๐ŸŽจ", ["story","storybook","storytell","bedtime","fairytale","fairy tale","picture book","comic","manga","illustrat","poem","poetry","poetic","novel","fable","creative-writing","creative writing","narrative","text-to-image","image-generation","image generation","img2img","image-to-image","diffusion","flux","sd-turbo","sticker","pixel-art","pixel art","typograph","voxel","avatar","manim","animation","text-to-video","image-to-video","claymation","postcard","sketch","doodle","watercolour","minecraftify","generative-art","kinetic typography","icon generator","logo","wallpaper","emoji","meme","theater","theatre","puppet","myth","legend","fantasy","witch","wizard","dragon","coloring","collage","video","film"]),
44
+ ("Companions, Personas & Reflective Toys", "๐Ÿ’š", ["companion","waifu","persona","character chat","virtual character","desktop pet","floating desktop","virtual pet","tsundere","yandere","girlfriend","confidant","journaling","voice journal","mood journal","gratitude journal","daily journal","reflection","oneiro","oracle","divination","prophecy","fortune teller","magic 8","tarot","arcana","seance","sรฉance","occult","constellation","lives unlived","unlived","parallel life","museum of","elegy","afterlife","loneliness","mindful","stargazing","imaginary friend","pen pal","penpal","diary","gratitude","affirmation","horoscope","zodiac","astrolog"]),
45
+ ("Assistants, Agents & Productivity", "๐Ÿ’ฌ", ["assistant","co-pilot","copilot","chatbot","chat assistant","chat with","agent","helper","concierge","butler","productivity","to-do","todo","task manager","note-taking","note taking","notetaking","meeting notes","summari","email","inbox","calendar","schedul","planner","reminder","advisor","recommend","knowledge base","knowledge hub","knowledgehub","second brain","retrieval-augmented","rag ","search engine","web search","personal ai","local ai","offline ai","home assistant","conversational","workflow","support","docu","research assistant","news","digest","faq","automation"]),
46
+ ]
47
+
48
+ OTHER_NAME, OTHER_EMOJI = "Other", "๐ŸŽ"
49
+
50
+ CAT_EMOJI = {name: emoji for name, emoji, _ in TAXONOMY}
51
+ CAT_EMOJI[OTHER_NAME] = OTHER_EMOJI
52
+ CAT_EMOJI["All"] = "๐ŸŽฒ"
53
+
54
+ # Filter choices: All, every real category, then Other (the grab-bag).
55
+ ALL_CATEGORIES = ["All"] + [name for name, _, _ in TAXONOMY] + [OTHER_NAME]
56
+
57
+ # Pre-compile keyword patterns once (left word-boundary).
58
+ _COMPILED = [(name, [re.compile(r"\b" + re.escape(k)) for k in kws])
59
+ for name, _, kws in TAXONOMY]
60
+ _CAMEL = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
61
+
62
+ # Framework / sponsor / model tags that carry no topical signal.
63
+ _NOISE_TOK = {
64
+ "gradio", "docker", "static", "build-small-hackathon", "backyard-ai",
65
+ "backyard ai", "tiny-titan", "tiny titan", "off-the-grid", "offgrid",
66
+ "nemotron", "minicpm", "llama-cpp", "openbmb", "zerogpu", "transformers",
67
+ "nvidia", "modal", "openai", "region:us",
68
+ }
69
+
70
+ # Official hackathon tracks โ†’ friendly label (shown as a card badge).
71
+ _TRACK_MAP = {
72
+ "track:backyard": "Backyard", "track:backyard-ai": "Backyard",
73
+ "track:wood": "Thousand Token Wood",
74
+ "track:thousand-token-wood": "Thousand Token Wood",
75
+ "track:thousand_token_wood": "Thousand Token Wood",
76
+ }
77
+
78
+ # โ”€โ”€ Categorization โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
79
+
80
+ def _categorize(title: str, raw: str, desc: str, tags: list) -> str:
81
+ keep = [t for t in tags if t.lower() not in _NOISE_TOK]
82
+ blob = f"{title} {raw.replace('-', ' ').replace('_', ' ')} {desc} {' '.join(keep)}"
83
+ blob = _CAMEL.sub(" ", blob).lower()
84
+ for name, pats in _COMPILED:
85
+ if any(p.search(blob) for p in pats):
86
+ return name
87
+ return OTHER_NAME
88
+
89
+
90
+ def _track_of(tags: list) -> str:
91
+ for t in tags:
92
+ lbl = _TRACK_MAP.get(t.lower())
93
+ if lbl:
94
+ return lbl
95
+ return ""
96
+
97
+ # โ”€โ”€ Data loading (cached once per process) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
98
+
99
+ _CACHE: list | None = None
100
+ _CACHE_TS: float = 0.0
101
+ CACHE_TTL = 600 # re-index at most every 10 min so live submissions join the pool
102
+
103
+ # Populated on every successful load so the UI can explain the pool size.
104
+ _STATS = {"listed": 0, "private": 0, "infra": 0, "pool": 0}
105
+
106
+
107
+ def _card_data(space) -> dict:
108
+ """Robustly pull the card dict โ€” SpaceCardData is NOT a plain dict on
109
+ modern huggingface_hub, so isinstance(dict) would silently drop everything."""
110
+ cd = getattr(space, "cardData", None)
111
+ if cd is None:
112
+ return {}
113
+ if hasattr(cd, "to_dict"):
114
+ try:
115
+ return cd.to_dict()
116
+ except Exception:
117
+ pass
118
+ if isinstance(cd, dict):
119
+ return dict(cd)
120
+ return {}
121
+
122
+
123
+ def load_spaces(force: bool = False) -> list:
124
+ """Index every PUBLIC submission in the hackathon org.
125
+
126
+ Private/gated spaces are skipped on purpose: they aren't in the public
127
+ listing, they'd be dead links for anyone the roulette sends there, and
128
+ skipping them makes the pool identical whether or not an HF token is
129
+ present in the environment โ€” so the draw is fair and reproducible.
130
+ """
131
+ global _CACHE, _CACHE_TS
132
+ now = time.time()
133
+ if not force and _CACHE is not None and (now - _CACHE_TS) < CACHE_TTL:
134
+ return _CACHE
135
+
136
+ api = HfApi()
137
+ spaces: list = []
138
+ listed = private = infra = 0
139
+ try:
140
+ for s in api.list_spaces(author=HACKATHON_ORG, full=True):
141
+ listed += 1
142
+ if getattr(s, "private", False):
143
+ private += 1
144
+ continue
145
+ raw = s.id.split("/")[-1] if "/" in s.id else s.id
146
+ if raw.lower() in INFRA_SPACES:
147
+ infra += 1
148
+ continue
149
+
150
+ cd = _card_data(s)
151
+ title = str(cd.get("title") or "").strip()
152
+ desc = str(cd.get("short_description") or cd.get("description") or "").strip()[:320]
153
+ sdk = str(cd.get("sdk") or "").strip()
154
+ tags = list(s.tags or [])
155
+ author = s.author or (s.id.split("/")[0] if "/" in s.id else "?")
156
+
157
+ spaces.append({
158
+ "id": s.id,
159
+ "name": title or raw.replace("-", " ").replace("_", " ").title(),
160
+ "raw_name": raw,
161
+ "author": author,
162
+ "tags": tags,
163
+ "likes": int(getattr(s, "likes", 0) or 0),
164
+ "url": f"https://huggingface.co/spaces/{s.id}",
165
+ "desc": desc,
166
+ "sdk": sdk,
167
+ "track": _track_of(tags),
168
+ "category": _categorize(title, raw, desc, tags),
169
+ })
170
+ except Exception as exc: # never let a network hiccup crash the Space
171
+ print(f"[hackathon-roulette] load error: {exc}")
172
+
173
+ # Only replace a good cache when we actually got data โ€” a transient fetch
174
+ # error shouldn't empty the pool mid-event.
175
+ if spaces or _CACHE is None:
176
+ _CACHE = spaces
177
+ _CACHE_TS = now
178
+ if spaces:
179
+ _STATS.update(listed=listed, private=private, infra=infra, pool=len(spaces))
180
+ return _CACHE
181
+
182
+
183
+ def _bucket(spaces: list, cat: str) -> list:
184
+ return [s for s in spaces if cat == "All" or s["category"] == cat]
185
+
186
+
187
+ def _display_tags(sp: dict) -> list:
188
+ out = []
189
+ for t in sp["tags"]:
190
+ tl = t.lower()
191
+ if tl in _NOISE_TOK:
192
+ continue
193
+ if t.startswith(("region:", "license:", "arxiv:", "doi:", "track:",
194
+ "sponsor:", "achievement:", "base_model:", "dataset:",
195
+ "pipeline_tag:", "badge-", "badge:")):
196
+ continue
197
+ out.append(t)
198
+ return out
199
+
200
+ # โ”€โ”€ HTML builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
201
+
202
+ def _stats(seen: list, spaces: list, cat: str) -> str:
203
+ seen_set = set(seen)
204
+ bucket = _bucket(spaces, cat)
205
+ total = len(bucket)
206
+ found = sum(1 for s in bucket if s["id"] in seen_set)
207
+ left = total - found
208
+ pct = round(found / total * 100) if total else 0
209
+
210
+ cats_html = ""
211
+ if cat == "All":
212
+ counts = {}
213
+ for s in spaces:
214
+ counts[s["category"]] = counts.get(s["category"], 0) + 1
215
+ ordered = [(n, e, counts.get(n, 0)) for n, e, _ in TAXONOMY]
216
+ ordered.append((OTHER_NAME, OTHER_EMOJI, counts.get(OTHER_NAME, 0)))
217
+ cats_html = "".join(
218
+ f'<span class="cat-count" title="{_html.escape(n)}">{e}&nbsp;{c}</span>'
219
+ for n, e, c in ordered if c
220
+ )
221
+
222
+ return f"""
223
+ <div class="stats-bar">
224
+ <div class="stat-row">
225
+ <div class="stat-item"><span class="snum">{total}</span><span class="slbl">spaces</span></div>
226
+ <span class="sdot">ยท</span>
227
+ <div class="stat-item"><span class="snum" style="color:#4ade80">{found}</span><span class="slbl">seen</span></div>
228
+ <span class="sdot">ยท</span>
229
+ <div class="stat-item"><span class="snum" style="color:#60a5fa">{left}</span><span class="slbl">to go</span></div>
230
+ <div class="pbar-wrap"><div class="pbar-fill" style="width:{pct}%"></div></div>
231
+ <span class="pbar-lbl">{pct}%</span>
232
+ </div>
233
+ {_why_note() if cat == "All" else ""}
234
+ {f'<div class="cat-counts">{cats_html}</div>' if cats_html else ""}
235
+ </div>"""
236
+
237
+
238
+ def _why_note() -> str:
239
+ pool = _STATS.get("pool", 0)
240
+ infra = _STATS.get("infra", 0)
241
+ priv = _STATS.get("private", 0)
242
+ # `listed` is the total the app actually enumerated โ€” i.e. exactly what the
243
+ # org's Spaces page shows the same viewer (946 with a token that can see the
244
+ # private spaces, 886 to an anonymous visitor). Compare against that so the
245
+ # number matches what the user sees on the Hub.
246
+ listed = _STATS.get("listed", 0) or (pool + infra + priv)
247
+ if not listed:
248
+ return ""
249
+ parts = []
250
+ if priv:
251
+ parts.append(f"<strong>{priv}</strong> are private or test spaces "
252
+ f"(hidden from the public listing, with dead links)")
253
+ if infra:
254
+ parts.append(f"<strong>{infra}</strong> are org info pages "
255
+ f"(<em>field-guide</em> &amp; <em>README</em>), not competition entries")
256
+ breakdown = f" โ€” {'; '.join(parts)}," if parts else ""
257
+ return f"""
258
+ <div class="why-line">
259
+ <span class="why-chip" tabindex="0">โ“˜ why {pool}, not {listed}?
260
+ <span class="why-pop">The org page lists <strong>{listed}</strong> Spaces{breakdown}
261
+ leaving <strong>{pool}</strong> real, public submissions to spin through.</span>
262
+ </span>
263
+ </div>"""
264
+
265
+
266
+ def _placeholder(msg: str = "") -> str:
267
+ inner = msg or "Pull the lever โ€” hit <strong>SPIN</strong> to discover a random project!"
268
+ return f"""
269
+ <div class="ph-wrap">
270
+ <div class="ph-dice">๐ŸŽฐ</div>
271
+ <p class="ph-txt">{inner}</p>
272
+ </div>"""
273
+
274
+
275
+ def _card(sp: dict, spin_count: int, position: int, bucket_total: int) -> str:
276
+ e = _html.escape
277
+ cat = sp["category"]
278
+ em = CAT_EMOJI.get(cat, "๐ŸŽ")
279
+
280
+ chips = "".join(f'<span class="tag-chip">{e(t)}</span>' for t in _display_tags(sp)[:7])
281
+ sdk_b = f'<span class="sdk-pill">{e(sp["sdk"])}</span>' if sp.get("sdk") else ""
282
+ track_b = f'<span class="track-pill">๐ŸŒฒ {e(sp["track"])}</span>' if sp.get("track") else ""
283
+
284
+ desc = sp.get("desc", "").strip()
285
+ desc_html = e(desc) if desc else "<em class='nodesc'>No description provided โ€” open it to find out!</em>"
286
+
287
+ # data-spin carries the monotonic spin counter so the client can reliably
288
+ # detect "a fresh result arrived" by polling for the value to change โ€” robust
289
+ # whether Gradio mounts a new node or mutates the existing one. The card
290
+ # animates via pure CSS (no inline <script>, which wouldn't run via innerHTML).
291
+ return f"""
292
+ <div class="space-card" data-spin="{spin_count}">
293
+ <div class="card-shine"></div>
294
+ <div class="card-meta">
295
+ <span class="cat-pill">{em} {e(cat)}</span>
296
+ {track_b}{sdk_b}
297
+ <span class="likes-pill">โค๏ธ {sp['likes']}</span>
298
+ </div>
299
+ <h2 class="card-h">{e(sp['name'])}</h2>
300
+ <p class="card-by">by <strong>{e(sp['author'])}</strong></p>
301
+ <p class="card-desc">{desc_html}</p>
302
+ <div class="card-chips">{chips}</div>
303
+ <div class="card-foot">
304
+ <a href="{sp['url']}" target="_blank" rel="noopener" class="open-btn">๐Ÿš€ Open Space</a>
305
+ <span class="draw-note">draw #{position} of {bucket_total}</span>
306
+ </div>
307
+ </div>"""
308
+
309
+
310
+ def _browse_list(spaces: list, cat: str, seen: list) -> str:
311
+ bucket = sorted(_bucket(spaces, cat), key=lambda s: -s["likes"])
312
+ if not bucket:
313
+ return '<p class="browse-empty">Nothing here yet.</p>'
314
+
315
+ seen_set = set(seen)
316
+ CAP = 60
317
+ rows = []
318
+ for s in bucket[:CAP]:
319
+ e = _html.escape
320
+ seen_mark = '<span class="seen-dot" title="already seen">โœ“</span>' if s["id"] in seen_set else ""
321
+ em = CAT_EMOJI.get(s["category"], "๐ŸŽ")
322
+ rows.append(f"""
323
+ <a class="browse-row" href="{s['url']}" target="_blank" rel="noopener">
324
+ <span class="br-em">{em}</span>
325
+ <span class="br-name">{e(s['name'])}{seen_mark}</span>
326
+ <span class="br-author">{e(s['author'])}</span>
327
+ <span class="br-likes">โค๏ธ {s['likes']}</span>
328
+ </a>""")
329
+ more = ""
330
+ if len(bucket) > CAP:
331
+ more = f'<p class="browse-more">+ {len(bucket) - CAP} more โ€” spin or pick a tighter category to see them</p>'
332
+ return f'<div class="browse-wrap">{"".join(rows)}{more}</div>'
333
+
334
+ # โ”€โ”€ Callbacks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
335
+
336
+ def on_load():
337
+ spaces = load_spaces()
338
+ n = len(spaces)
339
+ msg = (f"Loaded <strong>{n}</strong> projects from the {HACKATHON_NAME} โ€” ready to roll! ๐ŸŽ‰"
340
+ if n else "Couldn't reach the Hub right now โ€” try refreshing in a moment.")
341
+ return [], "All", 0, _stats([], spaces, "All"), _placeholder(msg)
342
+
343
+
344
+ def on_spin(seen: list, cat: str, spin_count: int):
345
+ spaces = load_spaces()
346
+ bucket = _bucket(spaces, cat)
347
+ if not bucket:
348
+ return _placeholder("No projects in this category."), seen, spin_count, _stats(seen, spaces, cat)
349
+
350
+ seen_set = set(seen)
351
+ unseen = [s for s in bucket if s["id"] not in seen_set]
352
+ banner = ""
353
+ if not unseen:
354
+ bucket_ids = {s["id"] for s in bucket}
355
+ seen = [sid for sid in seen if sid not in bucket_ids] # reset just this bucket
356
+ unseen = bucket
357
+ banner = '<div class="reset-note">๐ŸŽ‰ You\'ve seen every project here โ€” reshuffling the deck!</div>'
358
+
359
+ pick = random.choice(unseen)
360
+ new_seen = list(seen) + [pick["id"]]
361
+ new_count = spin_count + 1
362
+ position = sum(1 for s in bucket if s["id"] in set(new_seen))
363
+
364
+ card = banner + _card(pick, new_count, position, len(bucket))
365
+ return card, new_seen, new_count, _stats(new_seen, spaces, cat)
366
+
367
+
368
+ def on_category(cat: str, seen: list):
369
+ spaces = load_spaces()
370
+ em = CAT_EMOJI.get(cat, "๐ŸŽฒ")
371
+ n = len(_bucket(spaces, cat))
372
+ msg = f"{em} <strong>{_html.escape(cat)}</strong> โ€” {n} project{'s' if n != 1 else ''}. Hit SPIN!"
373
+ return cat, _stats(seen, spaces, cat), _placeholder(msg)
374
+
375
+
376
+ def on_browse(cat: str, seen: list):
377
+ spaces = load_spaces()
378
+ return _browse_list(spaces, cat, seen)
379
+
380
+ # โ”€โ”€ CSS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
381
+
382
+ CSS = r"""
383
+ /* โ”€โ”€ aurora backdrop โ”€โ”€ */
384
+ body, gradio-app { background:#070714 !important; }
385
+ .gradio-container { background: transparent !important; max-width: 760px !important; margin: 0 auto !important; position: relative; z-index: 1; }
386
+ body::before {
387
+ content:''; position:fixed; inset:-30%; z-index:0; pointer-events:none; filter: blur(22px);
388
+ background:
389
+ radial-gradient(38% 38% at 20% 22%, #7c3aed30, transparent 60%),
390
+ radial-gradient(36% 36% at 82% 18%, #2563eb2b, transparent 60%),
391
+ radial-gradient(44% 44% at 72% 82%, #f472b624, transparent 60%),
392
+ radial-gradient(40% 40% at 22% 86%, #22d3ee1f, transparent 60%);
393
+ animation: aurora 24s ease-in-out infinite alternate;
394
+ }
395
+ @keyframes aurora {
396
+ 0% { transform: translate3d(-3%,-2%,0) rotate(0deg) scale(1.05); }
397
+ 50% { transform: translate3d(3%, 2%,0) rotate(7deg) scale(1.16); }
398
+ 100% { transform: translate3d(-2%,3%,0) rotate(-6deg) scale(1.08); }
399
+ }
400
+ footer { display: none !important; }
401
+
402
+ .app-header { text-align: center; padding: 2rem 1rem 0.4rem; position: relative; }
403
+ .hdr-icon { font-size: 3.1rem; display: block; margin-bottom: .25rem;
404
+ animation: pop 1s cubic-bezier(.175,.885,.32,1.275) both;
405
+ filter: drop-shadow(0 0 14px #7c3aed88); }
406
+ @keyframes pop { 0%{transform:scale(0) rotate(-200deg);opacity:0} 75%{transform:scale(1.18) rotate(10deg)} 100%{transform:scale(1) rotate(0);opacity:1} }
407
+ .hdr-title { font-size: 2rem; font-weight: 900; margin: .1rem 0 .35rem;
408
+ background: linear-gradient(120deg,#a78bfa,#60a5fa 50%,#f472b6);
409
+ background-size: 200% auto; -webkit-background-clip: text; -webkit-text-fill-color: transparent;
410
+ background-clip: text; animation: shimmer 6s linear infinite; }
411
+ @keyframes shimmer { to { background-position: 200% center; } }
412
+ .hdr-sub { color: #50508a; font-size: .87rem; margin: 0; }
413
+ .hdr-sub strong { color: #8585bb; }
414
+
415
+ /* โ”€โ”€ mute toggle โ”€โ”€ */
416
+ .rl-mute { position:absolute; top:.4rem; right:.2rem; background:#0f0f28; border:1px solid #242456;
417
+ color:#c0c0e8; border-radius:10px; width:38px; height:38px; font-size:1.05rem; cursor:pointer;
418
+ transition:transform .12s, border-color .15s, box-shadow .2s; z-index:3; }
419
+ .rl-mute:hover { transform:scale(1.08); border-color:#7c3aed; box-shadow:0 0 14px #7c3aed55; }
420
+ .rl-mute.muted { opacity:.55; }
421
+
422
+ .stats-bar { background:#0f0f28cc; backdrop-filter:blur(6px); border:1px solid #1e1e48; border-radius:12px; padding:.6rem 1rem; margin:.5rem 0; }
423
+ .stat-row { display:flex; align-items:center; gap:.65rem; flex-wrap:wrap; }
424
+ .stat-item { display:flex; flex-direction:column; align-items:center; }
425
+ .snum { font-size:1.25rem; font-weight:800; color:#a78bfa; line-height:1.05; transition:color .3s; }
426
+ .slbl { font-size:.58rem; color:#404070; text-transform:uppercase; letter-spacing:.07em; }
427
+ .sdot { color:#1e1e48; }
428
+ .pbar-wrap { flex:1; min-width:60px; height:5px; background:#1e1e48; border-radius:3px; overflow:hidden; }
429
+ .pbar-fill { height:100%; background:linear-gradient(90deg,#7c3aed,#60a5fa,#f472b6); background-size:200% auto; transition:width .5s ease; animation:shimmer 3s linear infinite; }
430
+ .pbar-lbl { color:#50508a; font-size:.74rem; font-weight:600; min-width:2.4rem; text-align:right; }
431
+ .cat-counts { display:flex; gap:.32rem; flex-wrap:wrap; margin-top:.55rem; padding-top:.55rem; border-top:1px solid #16163a; }
432
+ .cat-count { background:#0a0a20; border:1px solid #1e1e48; border-radius:7px; padding:.1rem .42rem; font-size:.72rem; color:#6868a8; transition:transform .1s, border-color .15s; }
433
+ .cat-count:hover { transform:translateY(-1px); border-color:#34346e; }
434
+
435
+ /* โ”€โ”€ why-884 tooltip โ”€โ”€ */
436
+ .why-line { margin-top:.5rem; padding-top:.5rem; border-top:1px solid #16163a; }
437
+ .why-chip { position:relative; display:inline-block; font-size:.74rem; color:#6a6aa8;
438
+ background:#0a0a20; border:1px solid #20204a; border-radius:8px; padding:.12rem .5rem; cursor:help; outline:none; }
439
+ .why-chip:hover, .why-chip:focus { border-color:#7c3aed; color:#a78bfa; }
440
+ .why-pop { position:absolute; left:0; bottom:130%; width:300px; max-width:80vw; z-index:20;
441
+ background:#13132e; border:1px solid #34346e; border-radius:10px; padding:.6rem .75rem;
442
+ color:#a0a0d0; font-size:.78rem; line-height:1.5; text-align:left; box-shadow:0 8px 30px #000a;
443
+ opacity:0; transform:translateY(4px); pointer-events:none; transition:opacity .15s, transform .15s; }
444
+ .why-pop strong { color:#c8c8f0; } .why-pop em { color:#5fd58b; font-style:normal; }
445
+ .why-chip:hover .why-pop, .why-chip:focus .why-pop { opacity:1; transform:translateY(0); }
446
+
447
+ /* โ”€โ”€ SPIN button: glow + spin states โ”€โ”€ */
448
+ #spin-btn, #spin-btn button { background:linear-gradient(135deg,#7c3aed,#2563eb)!important; color:#fff!important; border:none!important;
449
+ border-radius:14px!important; font-size:1.3rem!important; font-weight:900!important; letter-spacing:.1em!important;
450
+ transition:transform .12s, box-shadow .2s!important; min-height:54px!important; animation: glow 2.6s ease-in-out infinite; }
451
+ @keyframes glow { 0%,100%{ box-shadow:0 4px 22px rgba(124,58,237,.45); } 50%{ box-shadow:0 4px 30px rgba(124,58,237,.7), 0 0 16px rgba(96,165,250,.45); } }
452
+ #spin-btn:hover, #spin-btn button:hover { transform:translateY(-2px)!important; box-shadow:0 7px 34px rgba(124,58,237,.75)!important; }
453
+ #spin-btn:active, #spin-btn button:active { transform:translateY(0) scale(.97)!important; }
454
+ #spin-btn.spinning, #spin-btn.spinning button { animation: glow-fast .45s ease-in-out infinite; }
455
+ @keyframes glow-fast { 0%,100%{ box-shadow:0 4px 26px rgba(124,58,237,.7); } 50%{ box-shadow:0 6px 44px rgba(244,114,182,.85), 0 0 28px rgba(96,165,250,.7); } }
456
+
457
+ #cat-dd label span { color:#50508a!important; font-size:.78rem!important; }
458
+ #cat-dd input, #cat-dd .wrap { background:#0f0f28!important; border-color:#1e1e48!important; color:#c0c0e8!important; }
459
+
460
+ /* โ”€โ”€ slot reel (shown while waiting for the draw) โ”€โ”€ */
461
+ #card-out { position: relative; }
462
+ .rl-reel-overlay { position:absolute; inset:0; min-height:172px; z-index:40; display:flex;
463
+ align-items:center; justify-content:center; background:#0a0a1c; border-radius:16px;
464
+ animation: ov-in .12s ease both; }
465
+ @keyframes ov-in { from { opacity:.35; } to { opacity:1; } }
466
+ .rl-reel-overlay .reel-wrap { padding:1rem; }
467
+ .reel-wrap { text-align:center; padding:2.2rem 1rem; }
468
+ .reel { width:100%; max-width:340px; height:78px; margin:0 auto; overflow:hidden; position:relative;
469
+ border:1px solid #2a2a5c; border-radius:14px; background:#0c0c22;
470
+ box-shadow:inset 0 0 30px #000, 0 0 26px #7c3aed66; }
471
+ .reel::before, .reel::after { content:''; position:absolute; top:0; bottom:0; width:46px; z-index:2; pointer-events:none; }
472
+ .reel::before { left:0; background:linear-gradient(90deg,#0c0c22,transparent); }
473
+ .reel::after { right:0; background:linear-gradient(270deg,#0c0c22,transparent); }
474
+ .reel-strip { display:inline-block; white-space:nowrap; font-size:2.7rem; line-height:78px;
475
+ animation: reel-roll .42s linear infinite; filter: blur(.3px); will-change: transform; }
476
+ @keyframes reel-roll { from { transform:translateX(0); } to { transform:translateX(-2em); } }
477
+ .reel-txt { color:#7c7cb8; margin-top:.85rem; font-size:.92rem; letter-spacing:.05em; animation: pulse-txt 1s ease-in-out infinite; }
478
+ @keyframes pulse-txt { 0%,100%{ opacity:.45; } 50%{ opacity:1; } }
479
+
480
+ /* โ”€โ”€ result card โ”€โ”€ */
481
+ .space-card { background:#0f0f28; border:1px solid #242456; border-radius:16px; padding:1.5rem; margin-top:.7rem;
482
+ position:relative; overflow:hidden; transform-origin:center top;
483
+ animation: card-drop .55s cubic-bezier(.175,.885,.32,1.275) both; }
484
+ .space-card::before { content:''; position:absolute; inset:0; z-index:0; border-radius:16px; background:linear-gradient(135deg,#7c3aed14,#2563eb14,#f472b614); pointer-events:none; }
485
+ @keyframes card-drop { 0%{opacity:0;transform:translateY(26px) scale(.94) rotateX(9deg);filter:blur(5px)} 60%{opacity:1;transform:translateY(-6px) scale(1.015)} 100%{opacity:1;transform:translateY(0) scale(1);filter:blur(0)} }
486
+ .card-shine { position:absolute; top:0; left:-60%; width:50%; height:100%; z-index:1; pointer-events:none;
487
+ background:linear-gradient(105deg, transparent, #ffffff22 45%, #ffffff33 50%, transparent 60%);
488
+ animation: shine 1.1s ease-out .25s both; }
489
+ @keyframes shine { from { left:-60%; } to { left:130%; } }
490
+ .space-card > *:not(.card-shine) { position:relative; z-index:2; }
491
+
492
+ .card-meta { display:flex; align-items:center; gap:.4rem; flex-wrap:wrap; margin-bottom:.85rem; position:relative; z-index:2; }
493
+ .cat-pill { background:#17173a; border:1px solid #34346e; border-radius:10px; padding:.2rem .6rem; font-size:.76rem; color:#a78bfa; }
494
+ .track-pill { background:#0c1f12; border:1px solid #1c4a2c; border-radius:10px; padding:.2rem .6rem; font-size:.72rem; color:#5fd58b; }
495
+ .sdk-pill { background:#0a1420; border:1px solid #1a3050; border-radius:10px; padding:.2rem .6rem; font-size:.7rem; color:#6aa8e0; text-transform:uppercase; letter-spacing:.04em; }
496
+ .likes-pill { margin-left:auto; color:#f472b6; font-weight:700; font-size:.86rem; }
497
+ .card-h { font-size:1.5rem; font-weight:900; color:#e6e6ff; margin:0 0 .15rem; line-height:1.18; position:relative; z-index:2; }
498
+ .card-by { color:#45457a; font-size:.86rem; margin:0 0 .8rem; position:relative; z-index:2; }
499
+ .card-by strong { color:#7070b0; }
500
+ .card-desc { color:#8888c0; font-size:.95rem; line-height:1.6; margin:0 0 1rem; position:relative; z-index:2; }
501
+ .card-desc .nodesc { color:#454575; }
502
+ .card-chips { display:flex; flex-wrap:wrap; gap:.3rem; margin-bottom:1.1rem; position:relative; z-index:2; }
503
+ .tag-chip { background:#080818; border:1px solid #1c1c3c; border-radius:6px; padding:.13rem .48rem; font-size:.68rem; color:#5a5a92; }
504
+ .card-foot { display:flex; align-items:center; gap:.8rem; }
505
+ .open-btn { display:inline-block; background:linear-gradient(135deg,#7c3aed,#2563eb); color:#fff!important; text-decoration:none!important;
506
+ border-radius:10px; padding:.55rem 1.3rem; font-weight:700; font-size:.92rem; box-shadow:0 2px 12px rgba(124,58,237,.35); transition:opacity .18s,transform .1s; }
507
+ .open-btn:hover { opacity:.9; transform:translateY(-1px); }
508
+ .draw-note { color:#3a3a68; font-size:.74rem; }
509
+
510
+ .ph-wrap { text-align:center; padding:2.6rem 1rem; color:#404070; }
511
+ .ph-dice { font-size:3.1rem; margin-bottom:.6rem; animation:float 3s ease-in-out infinite; }
512
+ @keyframes float { 0%,100%{transform:translateY(0) rotate(-4deg)} 50%{transform:translateY(-10px) rotate(4deg)} }
513
+ .ph-txt { font-size:1rem; } .ph-txt strong { color:#a78bfa; }
514
+
515
+ .reset-note { background:#15153a; border:1px solid #7c3aed44; border-radius:10px; padding:.55rem 1rem; color:#a78bfa; font-weight:600; text-align:center; margin-bottom:.6rem; font-size:.9rem; }
516
+
517
+ #browse-acc { margin-top:.4rem; }
518
+ .browse-wrap { display:flex; flex-direction:column; gap:.25rem; max-height:420px; overflow-y:auto; padding:.2rem; }
519
+ .browse-row { display:flex; align-items:center; gap:.6rem; padding:.45rem .6rem; border-radius:9px; background:#0c0c22; border:1px solid #17173a; text-decoration:none!important; transition:background .12s,border-color .12s; }
520
+ .browse-row:hover { background:#13132e; border-color:#34346e; }
521
+ .br-em { font-size:1rem; }
522
+ .br-name { color:#c8c8f0; font-weight:600; font-size:.9rem; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
523
+ .seen-dot { color:#4ade80; font-size:.78rem; margin-left:.4rem; }
524
+ .br-author { color:#4a4a80; font-size:.78rem; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
525
+ .br-likes { color:#f472b6; font-size:.78rem; font-weight:600; }
526
+ .browse-more, .browse-empty { color:#45457a; font-size:.82rem; text-align:center; padding:.6rem; }
527
+ """
528
+
529
+ # โ”€โ”€ Client-side JS (synthesized sound + confetti + slot reel) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
530
+ # Wired through Gradio's supported client hooks: SETUP_JS runs once via
531
+ # demo.load(js=...), REVEAL_JS runs after each spin via .then(js=...). Audio is
532
+ # synthesized with the Web Audio API (no external files) and only starts after
533
+ # the user's first click, satisfying browser autoplay rules. A mute toggle and
534
+ # throttled/short envelopes keep it tasteful rather than grating.
535
+
536
+ HEAD_HTML = r"""<script>
537
+ (function () {
538
+ if (window.RL) return;
539
+ const RL = window.RL = { muted: false };
540
+ let ctx = null;
541
+ const ac = () => {
542
+ try { if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return null; }
543
+ if (ctx && ctx.state === 'suspended') ctx.resume();
544
+ return ctx;
545
+ };
546
+ const blip = (freq, t0, dur, peak, type) => {
547
+ const c = ctx; if (!c) return;
548
+ const o = c.createOscillator(), g = c.createGain();
549
+ o.type = type || 'triangle'; o.frequency.setValueAtTime(freq, t0);
550
+ g.gain.setValueAtTime(0.0001, t0);
551
+ g.gain.exponentialRampToValueAtTime(peak, t0 + 0.01);
552
+ g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
553
+ o.connect(g); g.connect(c.destination);
554
+ o.start(t0); o.stop(t0 + dur + 0.02);
555
+ };
556
+ RL.tick = (v) => { if (RL.muted || !ac()) return; blip(360 + Math.random() * 240, ctx.currentTime, 0.05, v || 0.045, 'square'); };
557
+ let hov = 0;
558
+ RL.hover = () => { if (RL.muted) return; const n = performance.now(); if (n - hov < 90) return; hov = n; if (!ac()) return; blip(880, ctx.currentTime, 0.05, 0.02, 'sine'); };
559
+ RL.chime = () => {
560
+ if (RL.muted || !ac()) return;
561
+ const base = 523.25, steps = [1, 1.25, 1.5, 2.0];
562
+ steps.forEach((r, i) => blip(base * r, ctx.currentTime + i * 0.075, 0.5, 0.085, 'triangle'));
563
+ const c = ctx, t = c.currentTime, o = c.createOscillator(), g = c.createGain();
564
+ o.type = 'sine'; o.frequency.setValueAtTime(170, t); o.frequency.exponentialRampToValueAtTime(70, t + 0.25);
565
+ g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.11, t + 0.012); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.3);
566
+ o.connect(g); g.connect(c.destination); o.start(t); o.stop(t + 0.33);
567
+ };
568
+ let spinTimer = null;
569
+ RL.startSpinSound = () => {
570
+ if (RL.muted) return;
571
+ let delay = 38;
572
+ const step = () => { RL.tick(0.045); delay = Math.min(150, delay * 1.07); spinTimer = setTimeout(step, delay); };
573
+ step();
574
+ };
575
+ RL.stopSpinSound = () => { if (spinTimer) { clearTimeout(spinTimer); spinTimer = null; } };
576
+
577
+ // confetti burst on a fixed overlay canvas
578
+ let cv, cx, parts = [], raf = null;
579
+ const COLORS = ['#a78bfa', '#60a5fa', '#f472b6', '#4ade80', '#fbbf24', '#22d3ee'];
580
+ const mkCanvas = () => {
581
+ if (cv) return;
582
+ cv = document.createElement('canvas'); cv.id = 'rl-confetti';
583
+ cv.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:99999';
584
+ document.body.appendChild(cv); cx = cv.getContext('2d');
585
+ const rs = () => { cv.width = innerWidth * devicePixelRatio; cv.height = innerHeight * devicePixelRatio; cx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); };
586
+ rs(); addEventListener('resize', rs);
587
+ };
588
+ const loop = () => {
589
+ cx.clearRect(0, 0, innerWidth, innerHeight);
590
+ for (const p of parts) { p.vy += p.g; p.x += p.vx; p.y += p.vy; p.vx *= 0.99; p.rot += p.vr; p.life -= 0.011; }
591
+ parts = parts.filter(p => p.life > 0 && p.y < innerHeight + 50);
592
+ for (const p of parts) { cx.save(); cx.globalAlpha = Math.max(0, p.life); cx.translate(p.x, p.y); cx.rotate(p.rot); cx.fillStyle = p.col; cx.fillRect(-p.s / 2, -p.s / 2, p.s, p.s * 0.55); cx.restore(); }
593
+ raf = parts.length ? requestAnimationFrame(loop) : null;
594
+ };
595
+ RL.burst = (x, y) => {
596
+ mkCanvas();
597
+ for (let i = 0; i < 54; i++) { const a = Math.random() * Math.PI * 2, sp = 3 + Math.random() * 8; parts.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 4.5, g: 0.16 + Math.random() * 0.12, s: 5 + Math.random() * 7, rot: Math.random() * 6.28, vr: -0.3 + Math.random() * 0.6, col: COLORS[i % COLORS.length], life: 1 }); }
598
+ if (!raf) loop();
599
+ };
600
+
601
+ const MIN_SPIN = 650; // keep the wheel visibly spinning even when the draw is instant
602
+ const REEL = '<div class="reel-wrap"><div class="reel"><div class="reel-strip">๐ŸŽฎ ๐ŸŽจ ๐ŸŽ™๏ธ ๐Ÿฉบ ๐Ÿ’ผ ๐ŸŽ“ โš™๏ธ ๐ŸŒ ๐Ÿ’ฌ ๐Ÿ›ก๏ธ ๐Ÿ“ธ ๐Ÿ’š ๐ŸŽฎ ๐ŸŽจ ๐ŸŽ™๏ธ ๐Ÿฉบ<\/div><\/div><p class="reel-txt">spinning the wheelโ€ฆ<\/p><\/div>';
603
+ // The reel is an OVERLAY on top of #card-out โ€” never the card's own DOM. Two
604
+ // bugs this fixes: (1) mutating Gradio's Svelte-managed card node broke every
605
+ // spin after the first; (2) when a cached draw returns instantly the reel/
606
+ // sound used to flash by invisibly โ€” now the overlay (and ticks) stay up for
607
+ // at least MIN_SPIN before the reveal.
608
+ RL.startSpin = () => {
609
+ ac();
610
+ if (RL._poll) { clearInterval(RL._poll); RL._poll = null; }
611
+ if (RL._revealTimer) { clearTimeout(RL._revealTimer); RL._revealTimer = null; }
612
+ RL.stopSpinSound();
613
+ RL._spinStart = performance.now();
614
+ const prev = document.querySelector('#card-out .space-card');
615
+ RL._prevSpin = prev ? prev.getAttribute('data-spin') : '-1';
616
+ const host = document.getElementById('card-out');
617
+ if (host) {
618
+ host.style.position = 'relative';
619
+ let ov = host.querySelector('.rl-reel-overlay');
620
+ if (!ov) { ov = document.createElement('div'); ov.className = 'rl-reel-overlay'; host.appendChild(ov); }
621
+ ov.innerHTML = REEL; ov.style.display = 'flex';
622
+ }
623
+ const b = document.getElementById('spin-btn'); if (b) b.classList.add('spinning');
624
+ RL.startSpinSound();
625
+ // Poll for the new card (data-spin changes every draw); reveal once it has
626
+ // arrived AND the wheel has spun for at least MIN_SPIN.
627
+ const t0 = performance.now();
628
+ RL._poll = setInterval(() => {
629
+ const cur = document.querySelector('#card-out .space-card');
630
+ const arrived = cur && cur.getAttribute('data-spin') !== RL._prevSpin;
631
+ if (arrived || performance.now() - t0 > 6000) {
632
+ clearInterval(RL._poll); RL._poll = null;
633
+ const wait = Math.max(0, MIN_SPIN - (performance.now() - RL._spinStart));
634
+ RL._revealTimer = setTimeout(() => { RL._revealTimer = null; RL.land(); }, wait);
635
+ }
636
+ }, 40);
637
+ };
638
+ RL.land = () => {
639
+ RL.stopSpinSound();
640
+ const b = document.getElementById('spin-btn'); if (b) b.classList.remove('spinning');
641
+ const host = document.getElementById('card-out');
642
+ const ov = host && host.querySelector('.rl-reel-overlay');
643
+ if (ov) ov.remove(); // reveal the card underneath
644
+ const card = document.querySelector('#card-out .space-card');
645
+ if (card) { card.style.animation = 'none'; void card.offsetWidth; card.style.animation = ''; } // replay entrance on reveal
646
+ RL.chime();
647
+ let x = innerWidth / 2, y = innerHeight * 0.4;
648
+ if (card) { const r = card.getBoundingClientRect(); x = r.left + r.width / 2; y = r.top + Math.min(120, r.height * 0.35); }
649
+ RL.burst(x, y);
650
+ };
651
+
652
+ // delegated listeners survive Gradio re-renders
653
+ document.addEventListener('click', (e) => {
654
+ if (e.target.closest && e.target.closest('#spin-btn')) RL.startSpin();
655
+ const m = e.target.closest && e.target.closest('#rl-mute');
656
+ if (m) { RL.muted = !RL.muted; m.textContent = RL.muted ? '๐Ÿ”‡' : '๐Ÿ”Š'; m.classList.toggle('muted', RL.muted); if (RL.muted) RL.stopSpinSound(); }
657
+ }, true);
658
+ document.addEventListener('pointerover', (e) => { if (e.target.closest && e.target.closest('#spin-btn')) RL.hover(); }, true);
659
+ })();
660
+ </script>
661
+ """
662
+
663
+ # โ”€โ”€ UI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
664
+
665
+ with gr.Blocks(css=CSS, title="๐ŸŽฐ Hackathon Roulette", theme=gr.themes.Base(),
666
+ head=HEAD_HTML) as demo:
667
+ seen_st = gr.State([])
668
+ cat_st = gr.State("All")
669
+ spin_ctr = gr.State(0)
670
+
671
+ gr.HTML(f"""
672
+ <div class="app-header">
673
+ <button id="rl-mute" class="rl-mute" title="Sound on / off" aria-label="Toggle sound">๐Ÿ”Š</button>
674
+ <span class="hdr-icon">๐ŸŽฐ</span>
675
+ <h1 class="hdr-title">Hackathon Roulette</h1>
676
+ <p class="hdr-sub">Spin to discover hidden gems from the <strong>{HACKATHON_NAME}</strong></p>
677
+ </div>""")
678
+
679
+ stats_out = gr.HTML()
680
+
681
+ with gr.Row():
682
+ cat_dd = gr.Dropdown(choices=ALL_CATEGORIES, value="All", label="Browse by category",
683
+ elem_id="cat-dd", scale=3, interactive=True, filterable=False)
684
+ spin_btn = gr.Button("๐ŸŽฐ SPIN", elem_id="spin-btn", scale=2, min_width=160)
685
+
686
+ card_out = gr.HTML(elem_id="card-out")
687
+
688
+ with gr.Accordion("๐Ÿ“‹ Browse all in this category", open=False, elem_id="browse-acc") as browse_acc:
689
+ browse_out = gr.HTML()
690
+
691
+ # events
692
+ demo.load(on_load, outputs=[seen_st, cat_st, spin_ctr, stats_out, card_out])
693
+ spin_btn.click(
694
+ on_spin,
695
+ inputs=[seen_st, cat_st, spin_ctr],
696
+ outputs=[card_out, seen_st, spin_ctr, stats_out],
697
+ ) # the reveal (chime + confetti) is driven by the data-spin poller in HEAD_HTML
698
+ cat_dd.change(on_category, inputs=[cat_dd, seen_st], outputs=[cat_st, stats_out, card_out])
699
+ # keep the browse list in sync whenever the category changes or the panel opens
700
+ cat_dd.change(on_browse, inputs=[cat_dd, seen_st], outputs=[browse_out])
701
+ browse_acc.expand(on_browse, inputs=[cat_st, seen_st], outputs=[browse_out])
702
+
703
+
704
+ # Warm the cache at startup so the first visitor doesn't wait on the Hub.
705
+ try:
706
+ load_spaces()
707
+ except Exception as _exc: # pragma: no cover
708
+ print(f"[hackathon-roulette] prewarm skipped: {_exc}")
709
+
710
+
711
+ if __name__ == "__main__":
712
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ gradio>=6.0,<7
2
+ huggingface_hub>=0.25.0