Coverage for integrations / social / ui_hive_contest.py: 78.6%
14 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""UI for the Hive Contest — a single self-contained HTML page served
2from HARTOS that consumes the existing /api/hive/contest/* endpoints.
4Why this lives in HARTOS (not Nunba):
5 - HARTOS owns the backend endpoints. Serving the matching page
6 here keeps the UI wireable with zero Nunba dependency — any
7 browser (or embed) can hit http://localhost:6777/hive-contest
8 and see the live state.
9 - Nunba's SPA can still wrap the same page via iframe (shell_manifest
10 gets a panel pointing at this route). Single source of truth.
11 - Re-uses the HART Design System tokens that liquid_ui_service.py
12 already defines (ds-btn, ds-body-md, ds-elevation-*, MD3 spacing).
13 No parallel CSS. No JS framework. Vanilla fetch + DOM.
15Surface:
16 GET /hive-contest — single HTML page (public, no auth)
18All dynamic content (info, leaderboard, MCP snippet) is loaded via
19XHR from the existing API blueprint. This page is a thin renderer;
20the source-of-truth stays in integrations.agent_engine.hive_contest.
21"""
22from __future__ import annotations
24from flask import Blueprint, Response
26hive_contest_ui_bp = Blueprint('hive_contest_ui', __name__)
29def _design_system_link() -> str:
30 """Pull the HART Design System tokens from liquid_ui_service.
32 We don't duplicate the tokens — the CSS is imported at render time
33 from the same module the desktop shell uses. Import failures fall
34 back to a minimal built-in that still renders readable content.
35 """
36 try:
37 from integrations.agent_engine.liquid_ui_service import (
38 LiquidUIService,
39 )
40 # LiquidUIService has the full MD3 token block inside
41 # render_desktop_shell. We extract a trimmed subset — the
42 # design-system vars + the button/card primitives — so the
43 # contest page can style itself without pulling the shell.
44 except Exception:
45 pass
46 return _FALLBACK_CSS
49# Minimal MD3-flavored CSS — values mirror liquid_ui_service's
50# HART Design System tokens so anything rendered inside the desktop
51# shell inherits (when framed) or stands alone (when visited directly).
52_FALLBACK_CSS = """
53:root{
54 --ds-font-body:"Inter",-apple-system,"Segoe UI",Roboto,sans-serif;
55 --ds-font-mono:"JetBrains Mono","Fira Code",monospace;
56 --ds-space-1:4px; --ds-space-2:8px; --ds-space-3:12px; --ds-space-4:16px;
57 --ds-space-5:20px; --ds-space-6:24px; --ds-space-8:32px; --ds-space-10:40px;
58 --ds-space-12:48px; --ds-space-16:64px;
59 --ds-radius-sm:8px; --ds-radius-md:12px; --ds-radius-lg:16px;
60 --ds-radius-xl:24px; --ds-radius-full:9999px;
61 --hart-bg:#0F0E17; --hart-surface:#1a1a2e; --hart-accent:#6C63FF;
62 --hart-active:#00e676; --hart-text:#e0e0e0; --hart-muted:#78909c;
63 --hart-error:#FF6B6B; --hart-caution:#ffab40;
64 --hart-track-digital:#6C63FF;
65 --hart-track-embodied:#00e676;
66 --hart-track-wellness:#ffab40;
67 --ds-elevation-1:0 1px 3px 1px rgba(0,0,0,0.15),0 1px 2px rgba(0,0,0,0.3);
68 --ds-elevation-2:0 2px 6px 2px rgba(0,0,0,0.15),0 1px 2px rgba(0,0,0,0.3);
69 --ds-elevation-3:0 4px 8px 3px rgba(0,0,0,0.15),0 1px 3px rgba(0,0,0,0.3);
70}
71*,*::before,*::after{box-sizing:border-box}
72html,body{margin:0;padding:0;min-height:100vh;background:
73 radial-gradient(1200px 600px at 10% -10%,rgba(108,99,255,0.12),transparent 60%),
74 radial-gradient(1000px 500px at 100% 10%,rgba(0,230,118,0.08),transparent 60%),
75 linear-gradient(135deg,#0F0E17 0%,#16213e 100%);
76 color:var(--hart-text);font-family:var(--ds-font-body);line-height:1.5}
77a{color:var(--hart-accent);text-decoration:none} a:hover{text-decoration:underline}
78.page{max-width:1100px;margin:0 auto;padding:var(--ds-space-8) var(--ds-space-6)}
79.hero{padding:var(--ds-space-10) 0;text-align:left}
80.hero h1{font-size:44px;line-height:1.1;margin:0 0 var(--ds-space-4);
81 font-weight:700;letter-spacing:-0.5px;
82 background:linear-gradient(90deg,#e0e0e0,#c7c2ff);
83 -webkit-background-clip:text;background-clip:text;color:transparent}
84.hero .tagline{font-size:18px;color:var(--hart-muted);max-width:720px;margin:0}
85.hero .window-pill{display:inline-flex;align-items:center;gap:var(--ds-space-2);
86 margin-top:var(--ds-space-6);padding:6px 14px;border-radius:var(--ds-radius-full);
87 background:rgba(0,230,118,0.1);border:1px solid rgba(0,230,118,0.3);
88 font-size:13px;color:var(--hart-active)}
89.hero .window-pill::before{content:"";width:8px;height:8px;border-radius:50%;
90 background:var(--hart-active);animation:pulse 2s ease-in-out infinite}
91@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
92.principle{margin-top:var(--ds-space-6);padding:var(--ds-space-4) var(--ds-space-5);
93 border-left:3px solid var(--hart-accent);background:rgba(108,99,255,0.06);
94 border-radius:0 var(--ds-radius-md) var(--ds-radius-md) 0;
95 font-size:14px;color:#d0d0d0;max-width:820px}
96.section{margin-top:var(--ds-space-12)}
97.section h2{font-size:22px;font-weight:600;margin:0 0 var(--ds-space-5);
98 display:flex;align-items:center;gap:var(--ds-space-3)}
99.section h2 .muted{font-size:14px;font-weight:400;color:var(--hart-muted)}
100.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));
101 gap:var(--ds-space-5)}
102.card{background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);
103 border-radius:var(--ds-radius-lg);padding:var(--ds-space-6);
104 box-shadow:var(--ds-elevation-1);transition:transform 200ms,box-shadow 200ms,
105 border-color 200ms}
106.card:hover{transform:translateY(-2px);box-shadow:var(--ds-elevation-3);
107 border-color:rgba(108,99,255,0.25)}
108.card h3{margin:0 0 var(--ds-space-2);font-size:18px;font-weight:600}
109.card .track-badge{display:inline-block;padding:3px 10px;border-radius:
110 var(--ds-radius-full);font-size:11px;font-weight:600;text-transform:uppercase;
111 letter-spacing:0.8px;margin-bottom:var(--ds-space-3)}
112.card[data-track="digital"] .track-badge{background:rgba(108,99,255,0.15);
113 color:var(--hart-track-digital)}
114.card[data-track="embodied"] .track-badge{background:rgba(0,230,118,0.15);
115 color:var(--hart-track-embodied)}
116.card[data-track="human_wellness"] .track-badge{background:rgba(255,171,64,0.15);
117 color:var(--hart-track-wellness)}
118.card .card-desc{color:#c0c0c0;font-size:14px;line-height:1.55;
119 margin:0 0 var(--ds-space-4)}
120.card .card-examples{list-style:none;padding:0;margin:0;font-size:13px;
121 color:var(--hart-muted)}
122.card .card-examples li{padding:4px 0;padding-left:18px;position:relative}
123.card .card-examples li::before{content:"→";position:absolute;left:0;
124 color:var(--hart-accent);opacity:0.6}
125.track-filter{display:inline-flex;gap:var(--ds-space-2);margin-bottom:
126 var(--ds-space-5);background:rgba(255,255,255,0.04);
127 border:1px solid rgba(255,255,255,0.08);border-radius:var(--ds-radius-full);
128 padding:4px}
129.track-filter button{background:transparent;border:none;color:var(--hart-muted);
130 padding:8px 18px;border-radius:var(--ds-radius-full);cursor:pointer;
131 font-family:var(--ds-font-body);font-size:13px;font-weight:500;
132 transition:background 150ms,color 150ms}
133.track-filter button:hover{color:var(--hart-text)}
134.track-filter button.active{background:var(--hart-accent);color:white;
135 box-shadow:var(--ds-elevation-1)}
136.leaderboard{background:rgba(255,255,255,0.04);border:1px solid
137 rgba(255,255,255,0.08);border-radius:var(--ds-radius-lg);overflow:hidden}
138.leaderboard table{width:100%;border-collapse:collapse}
139.leaderboard th,.leaderboard td{text-align:left;padding:var(--ds-space-3)
140 var(--ds-space-5);font-size:14px;border-bottom:1px solid rgba(255,255,255,0.05)}
141.leaderboard th{background:rgba(108,99,255,0.08);font-weight:600;color:#b0b0b0;
142 font-size:12px;text-transform:uppercase;letter-spacing:0.6px}
143.leaderboard tr:last-child td{border-bottom:none}
144.leaderboard tr:hover td{background:rgba(255,255,255,0.03)}
145.leaderboard .rank{font-family:var(--ds-font-mono);color:var(--hart-muted);
146 width:60px}
147.leaderboard .rank.top-1{color:#ffd700}
148.leaderboard .rank.top-2{color:#c0c0c0}
149.leaderboard .rank.top-3{color:#cd7f32}
150.leaderboard .score{font-family:var(--ds-font-mono);color:var(--hart-active);
151 text-align:right;font-weight:600}
152.leaderboard .empty{padding:var(--ds-space-10) var(--ds-space-5);text-align:center;
153 color:var(--hart-muted);font-size:14px}
154.split-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--ds-space-6);
155 margin-top:var(--ds-space-6)}
156@media (max-width:820px){.split-row{grid-template-columns:1fr}}
157.snippet-block{background:#0b0a14;border:1px solid rgba(255,255,255,0.1);
158 border-radius:var(--ds-radius-md);padding:var(--ds-space-4);font-family:
159 var(--ds-font-mono);font-size:13px;line-height:1.6;overflow-x:auto;
160 white-space:pre;position:relative;color:#d0d0d0}
161.copy-btn{position:absolute;top:var(--ds-space-3);right:var(--ds-space-3);
162 background:var(--hart-accent);color:white;border:none;border-radius:
163 var(--ds-radius-sm);padding:6px 12px;font-size:12px;font-weight:500;
164 cursor:pointer;transition:transform 150ms,background 150ms}
165.copy-btn:hover{background:#5a52d8;transform:translateY(-1px)}
166.copy-btn.copied{background:var(--hart-active);color:#0F0E17}
167.join-form{background:rgba(255,255,255,0.04);border:1px solid
168 rgba(255,255,255,0.08);border-radius:var(--ds-radius-lg);padding:
169 var(--ds-space-6)}
170.join-form label{display:block;font-size:12px;font-weight:600;
171 text-transform:uppercase;letter-spacing:0.6px;color:#b0b0b0;
172 margin:var(--ds-space-4) 0 var(--ds-space-2)}
173.join-form label:first-of-type{margin-top:0}
174.join-form select,.join-form input{width:100%;background:rgba(0,0,0,0.25);
175 border:1px solid rgba(255,255,255,0.12);color:var(--hart-text);
176 padding:10px var(--ds-space-4);border-radius:var(--ds-radius-sm);
177 font-family:inherit;font-size:14px;outline:none;
178 transition:border-color 150ms}
179.join-form select:focus,.join-form input:focus{border-color:var(--hart-accent)}
180.ds-btn{display:inline-flex;align-items:center;justify-content:center;
181 gap:var(--ds-space-2);padding:12px var(--ds-space-8);
182 border-radius:var(--ds-radius-full);font-family:inherit;font-size:14px;
183 font-weight:600;cursor:pointer;border:none;outline:none;
184 background:var(--hart-accent);color:white;
185 box-shadow:var(--ds-elevation-1);transition:box-shadow 200ms,
186 background 150ms,transform 150ms}
187.ds-btn:hover{background:#5a52d8;box-shadow:var(--ds-elevation-2);
188 transform:translateY(-1px)}
189.ds-btn:active{transform:translateY(0)}
190.ds-btn[disabled]{opacity:0.5;cursor:not-allowed;transform:none}
191.ds-btn.large{padding:14px var(--ds-space-10);font-size:15px}
192.join-result{margin-top:var(--ds-space-4);padding:var(--ds-space-3)
193 var(--ds-space-4);border-radius:var(--ds-radius-sm);font-size:13px;
194 display:none}
195.join-result.ok{background:rgba(0,230,118,0.1);border:1px solid
196 rgba(0,230,118,0.3);color:var(--hart-active);display:block}
197.join-result.err{background:rgba(255,107,107,0.1);border:1px solid
198 rgba(255,107,107,0.3);color:var(--hart-error);display:block}
199.prize-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));
200 gap:var(--ds-space-4);margin-top:var(--ds-space-4)}
201.prize-card{background:rgba(108,99,255,0.06);border:1px solid
202 rgba(108,99,255,0.2);border-radius:var(--ds-radius-md);
203 padding:var(--ds-space-5)}
204.prize-card h4{margin:0 0 var(--ds-space-2);font-size:15px;font-weight:600;
205 color:var(--hart-accent)}
206.prize-card p{margin:0;color:#c0c0c0;font-size:13px;line-height:1.5}
207.footer{margin-top:var(--ds-space-16);padding-top:var(--ds-space-6);
208 border-top:1px solid rgba(255,255,255,0.08);color:var(--hart-muted);
209 font-size:12px;text-align:center}
210.footer a{color:var(--hart-muted)}
211.skeleton{animation:skeleton 1.5s ease-in-out infinite}
212@keyframes skeleton{0%,100%{opacity:0.4}50%{opacity:0.7}}
213.spinner{display:inline-block;width:14px;height:14px;border-radius:50%;
214 border:2px solid rgba(255,255,255,0.25);border-top-color:var(--hart-accent);
215 animation:spin 0.7s linear infinite}
216@keyframes spin{to{transform:rotate(360deg)}}
217"""
220_HTML = r"""<!doctype html>
221<html lang="en">
222<head>
223<meta charset="utf-8">
224<meta name="viewport" content="width=device-width, initial-scale=1">
225<title>Hive Contest — HART OS</title>
226<style>__CSS__</style>
227</head>
228<body>
229<main class="page">
231<section class="hero">
232 <h1 id="contest-name">Hive Contest</h1>
233 <p class="tagline" id="contest-tagline">Loading…</p>
234 <div class="window-pill" id="contest-window">contest window loading</div>
235 <blockquote class="principle" id="contest-principle"></blockquote>
236</section>
238<section class="section" id="tracks-section">
239 <h2>Three tracks <span class="muted">— pick the one that matches what you ship</span></h2>
240 <div class="cards" id="tracks-grid"></div>
241</section>
243<section class="section">
244 <h2>Leaderboard <span class="muted" id="lb-meta"></span></h2>
245 <div class="track-filter" id="track-filter" role="tablist">
246 <button data-filter="" class="active">Overall</button>
247 <button data-filter="digital">Digital</button>
248 <button data-filter="embodied">Embodied</button>
249 <button data-filter="human_wellness">Wellness</button>
250 </div>
251 <div class="leaderboard" id="leaderboard-box">
252 <div class="empty skeleton">loading leaderboard…</div>
253 </div>
254</section>
256<section class="section split-row">
257 <div>
258 <h2>Plug in your Claude Code</h2>
259 <p style="color:#c0c0c0;font-size:14px;line-height:1.6">
260 Point Claude Code at the local HART OS MCP server. Every recipe your
261 agent ships, every benchmark it proves, every episode it executes
262 lands in your wallet as <code>season_spark</code>. Same 90/9/1 split
263 as every other Spark transaction — no separate payout pool.
264 </p>
265 <div class="snippet-block">
266 <button class="copy-btn" id="copy-mcp">Copy</button>
267 <code id="mcp-snippet" class="skeleton">loading MCP config…</code>
268 </div>
269 </div>
271 <div>
272 <h2>Join the contest</h2>
273 <form class="join-form" id="join-form">
274 <label for="join-track">Track</label>
275 <select id="join-track" name="track">
276 <option value="digital">Digital Intelligence</option>
277 <option value="embodied">Embodied Skill</option>
278 <option value="human_wellness">Human Wellness</option>
279 </select>
280 <label for="join-github">GitHub handle (optional)</label>
281 <input type="text" id="join-github" name="github"
282 placeholder="your-handle" autocomplete="off">
283 <label for="join-email">Email (optional)</label>
284 <input type="email" id="join-email" name="email"
285 placeholder="you@example.com" autocomplete="off">
286 <div style="margin-top:var(--ds-space-5)">
287 <button type="submit" class="ds-btn large" id="join-btn">
288 Register
289 </button>
290 </div>
291 <div class="join-result" id="join-result"></div>
292 </form>
293 </div>
294</section>
296<section class="section">
297 <h2>Ideas wall <span class="muted" id="ideas-meta"></span></h2>
298 <p style="color:#c0c0c0;font-size:14px;max-width:820px;margin:0 0 var(--ds-space-5)">
299 Drop an idea here — or say <em>"I have a contest idea"</em> to your
300 Nunba companion agent and the Contest Curator will capture it for
301 you. Ideas land in the same social feed (upvotes, comments) and
302 earn you a first Spark under <code>contest:idea_submitted</code>.
303 Floating ideas panel on
304 <a href="https://hevolve.ai" target="_blank">hevolve.ai</a> streams
305 new entries live via SSE.
306 </p>
307 <div class="split-row">
308 <div>
309 <form class="join-form" id="idea-form">
310 <label for="idea-track">Track</label>
311 <select id="idea-track">
312 <option value="digital">Digital Intelligence</option>
313 <option value="embodied">Embodied Skill</option>
314 <option value="human_wellness">Human Wellness</option>
315 </select>
316 <label for="idea-title">Idea title</label>
317 <input type="text" id="idea-title" maxlength="200"
318 placeholder="A companion that reminds me to walk">
319 <label for="idea-desc">Description</label>
320 <textarea id="idea-desc" rows="5" maxlength="4000"
321 style="width:100%;background:rgba(0,0,0,0.25);
322 border:1px solid rgba(255,255,255,0.12);color:var(--hart-text);
323 padding:10px var(--ds-space-4);border-radius:
324 var(--ds-radius-sm);font-family:inherit;font-size:14px;
325 outline:none;resize:vertical"
326 placeholder="What would it do? Who does it help?
327How does the hive make it possible?"></textarea>
328 <div style="margin-top:var(--ds-space-5)">
329 <button type="submit" class="ds-btn" id="idea-btn">
330 Submit idea
331 </button>
332 </div>
333 <div class="join-result" id="idea-result"></div>
334 </form>
335 </div>
336 <div>
337 <div class="leaderboard" id="ideas-box">
338 <div class="empty skeleton">loading ideas…</div>
339 </div>
340 </div>
341 </div>
342</section>
344<section class="section">
345 <h2>Prizes & recognition</h2>
346 <div class="prize-grid">
347 <div class="prize-card">
348 <h4>90 / 9 / 1 Spark split</h4>
349 <p>Every prize Spark follows the canonical split — 90% to the
350 submitter, 9% to the infra node that ran the submission, 1% to
351 the central hive. Same split as every other Spark transaction.</p>
352 </div>
353 <div class="prize-card">
354 <h4>Top 3 per track</h4>
355 <p>Auto-featured on <code>docs.hevolve.ai</code>. Biggest mover
356 each week gets a shoutout from Quest — the contest-host daemon
357 agent.</p>
358 </div>
359 <div class="prize-card">
360 <h4>Embodied & Wellness first</h4>
361 <p>Physical-world and real-wellness submissions are celebrated
362 over pure-digital. A bright future requires leaving the screen.</p>
363 </div>
364 <div class="prize-card" style="grid-column: 1 / -1;
365 background: rgba(255,171,64,0.08); border-color: rgba(255,171,64,0.35)">
366 <h4 style="color: var(--hart-caution)">Help us co-create — hardware SDKs welcome</h4>
367 <p>We're a startup constrained by resources to validate every
368 feature alone, so we co-create with the community. Specifically
369 looking for help bridging <strong>BLE devices, EEG headsets, robot
370 platforms (LeRobot, ROS, Unitree, Spot), accessibility hardware,
371 smart-home sensors</strong> — anything with an SDK that lets the
372 hive perceive or act in the real world. Trust the open code,
373 the public Spark ledger, the crowdsourced compute economy, and
374 the constitutional guardrails — even if you don't know the
375 strangers shipping work alongside you; the system is the trust.
376 Share with one friend or family member who has a relevant skill.</p>
377 </div>
378 </div>
379</section>
381<div class="footer">
382 Public canonical page: <a id="public-url-link"
383 href="https://hevolve.ai/hive_contest" target="_blank">
384 hevolve.ai/hive_contest</a> ·
385 Source: <code>integrations/agent_engine/hive_contest.py</code> ·
386 <a href="/api/hive/contest/info">API /info</a> ·
387 <a href="/api/hive/contest/leaderboard">API /leaderboard</a> ·
388 Quest posts weekly standings.
389</div>
390</main>
392<script>
393(function(){
394 "use strict";
396 // ─── Tiny helpers ────────────────────────────────────────────────
397 const $ = (sel) => document.querySelector(sel);
398 const $$ = (sel) => Array.from(document.querySelectorAll(sel));
399 const esc = (s) => String(s == null ? '' : s)
400 .replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
401 .replace(/"/g,'"').replace(/'/g,''');
403 function fmtDate(iso) {
404 if (!iso) return '';
405 try {
406 const d = new Date(iso);
407 return d.toLocaleDateString(undefined, {
408 year: 'numeric', month: 'short', day: 'numeric',
409 });
410 } catch (_e) { return iso; }
411 }
413 function daysUntil(iso) {
414 if (!iso) return null;
415 try {
416 const ms = new Date(iso).getTime() - Date.now();
417 return Math.max(0, Math.ceil(ms / (86400 * 1000)));
418 } catch (_e) { return null; }
419 }
421 // ─── Load contest info ───────────────────────────────────────────
422 async function loadInfo() {
423 try {
424 const r = await fetch('/api/hive/contest/info');
425 const j = await r.json();
426 const info = (j && j.data) || {};
427 $('#contest-name').textContent = info.name || 'Hive Contest';
428 $('#contest-tagline').textContent = info.tagline || '';
429 $('#contest-principle').textContent = info.humans_first_principle || '';
431 const start = fmtDate(info.starts_at);
432 const end = fmtDate(info.ends_at);
433 const remaining = daysUntil(info.ends_at);
434 const pill = $('#contest-window');
435 if (remaining !== null && remaining > 0) {
436 pill.textContent = `${remaining} day${remaining === 1 ? '' : 's'} left · `
437 + `${start} → ${end}`;
438 } else if (remaining === 0) {
439 pill.textContent = `closes today · ${start} → ${end}`;
440 pill.style.background = 'rgba(255,171,64,0.12)';
441 pill.style.borderColor = 'rgba(255,171,64,0.4)';
442 pill.style.color = 'var(--hart-caution)';
443 } else {
444 pill.textContent = `${start} → ${end}`;
445 }
447 renderTracks(info.tracks || []);
448 // Public canonical URL — env-overridable on the server, surfaced
449 // here so a staging deploy can route the footer link to the
450 // matching staging app page automatically.
451 const link = $('#public-url-link');
452 if (link && info.public_url) {
453 link.href = info.public_url;
454 try {
455 const u = new URL(info.public_url);
456 link.textContent = (u.host + u.pathname).replace(/\/$/, '');
457 } catch (_e) { link.textContent = info.public_url; }
458 }
459 } catch (e) {
460 $('#contest-tagline').textContent =
461 'Couldn\'t reach /api/hive/contest/info — is HART OS running on this host?';
462 $('#contest-window').textContent = 'offline';
463 }
464 }
466 function renderTracks(tracks) {
467 const grid = $('#tracks-grid');
468 grid.innerHTML = '';
469 for (const t of tracks) {
470 const card = document.createElement('article');
471 card.className = 'card';
472 card.setAttribute('data-track', t.id || '');
473 const examples = (t.example_contributions || [])
474 .map((e) => `<li>${esc(e)}</li>`).join('');
475 card.innerHTML = `
476 <span class="track-badge">${esc(t.id || '')}</span>
477 <h3>${esc(t.name || '')}</h3>
478 <p class="card-desc">${esc(t.description || '')}</p>
479 <ul class="card-examples">${examples}</ul>
480 `;
481 card.addEventListener('click', () => filterTo(t.id));
482 grid.appendChild(card);
483 }
484 }
486 // ─── Leaderboard ─────────────────────────────────────────────────
487 let currentFilter = '';
489 function filterTo(track) {
490 currentFilter = track || '';
491 $$('#track-filter button').forEach((b) => {
492 b.classList.toggle('active',
493 (b.getAttribute('data-filter') || '') === currentFilter);
494 });
495 loadLeaderboard();
496 }
498 $$('#track-filter button').forEach((b) => {
499 b.addEventListener('click', () => filterTo(b.getAttribute('data-filter')));
500 });
502 async function loadLeaderboard() {
503 const box = $('#leaderboard-box');
504 box.innerHTML = '<div class="empty skeleton">loading leaderboard…</div>';
505 $('#lb-meta').textContent = '';
506 try {
507 const qs = currentFilter ? `?track=${encodeURIComponent(currentFilter)}` : '';
508 const r = await fetch(`/api/hive/contest/leaderboard${qs}&limit=15`
509 .replace('?&', '?'));
510 const j = await r.json();
511 const rows = (j && j.data) || [];
512 const meta = (j && j.meta) || {};
513 $('#lb-meta').textContent =
514 `${rows.length} entr${rows.length === 1 ? 'y' : 'ies'} · `
515 + `${esc(meta.track || 'overall')} track`;
516 if (!rows.length) {
517 box.innerHTML =
518 '<div class="empty">No entries yet — be the first. '
519 + 'Ship a recipe and refresh.</div>';
520 return;
521 }
522 const rowsHtml = rows.map((row, idx) => {
523 const rank = row.rank || (idx + 1);
524 const rankCls = rank <= 3 ? `rank top-${rank}` : 'rank';
525 const name = row.display_name || row.user_id || 'anon';
526 const score = (row.score != null ? row.score :
527 (row.season_spark != null ? row.season_spark : 0));
528 return `
529 <tr>
530 <td class="${rankCls}">#${rank}</td>
531 <td>${esc(name)}</td>
532 <td style="color:var(--hart-muted);font-size:12px">
533 ${esc(row.track || 'overall')}
534 </td>
535 <td class="score">${Number(score).toLocaleString()}</td>
536 </tr>`;
537 }).join('');
538 box.innerHTML = `
539 <table>
540 <thead><tr>
541 <th>Rank</th><th>Contributor</th><th>Track</th><th>Spark</th>
542 </tr></thead>
543 <tbody>${rowsHtml}</tbody>
544 </table>`;
545 } catch (e) {
546 box.innerHTML = '<div class="empty">Leaderboard unavailable.</div>';
547 }
548 }
550 // ─── MCP snippet ─────────────────────────────────────────────────
551 async function loadSnippet() {
552 try {
553 const r = await fetch('/api/hive/contest/claude-code.mcp');
554 const txt = await r.text();
555 const el = $('#mcp-snippet');
556 el.textContent = txt;
557 el.classList.remove('skeleton');
558 } catch (e) {
559 $('#mcp-snippet').textContent = '# unable to load snippet';
560 }
561 }
563 $('#copy-mcp').addEventListener('click', async () => {
564 const txt = $('#mcp-snippet').textContent || '';
565 try {
566 await navigator.clipboard.writeText(txt);
567 const btn = $('#copy-mcp');
568 btn.classList.add('copied');
569 btn.textContent = 'Copied';
570 setTimeout(() => {
571 btn.classList.remove('copied');
572 btn.textContent = 'Copy';
573 }, 1500);
574 } catch (e) {
575 // Fallback: select the snippet so the user can Ctrl+C
576 const range = document.createRange();
577 range.selectNodeContents($('#mcp-snippet'));
578 const sel = window.getSelection();
579 sel.removeAllRanges();
580 sel.addRange(range);
581 }
582 });
584 // ─── Join form ───────────────────────────────────────────────────
585 $('#join-form').addEventListener('submit', async (e) => {
586 e.preventDefault();
587 const btn = $('#join-btn');
588 const result = $('#join-result');
589 result.className = 'join-result';
590 result.style.display = 'none';
591 btn.disabled = true;
592 const orig = btn.textContent;
593 btn.innerHTML = '<span class="spinner"></span> Joining…';
594 try {
595 const body = {
596 track: $('#join-track').value,
597 github: $('#join-github').value.trim() || undefined,
598 email: $('#join-email').value.trim() || undefined,
599 };
600 const r = await fetch('/api/hive/contest/join', {
601 method: 'POST',
602 headers: {'Content-Type': 'application/json'},
603 credentials: 'include',
604 body: JSON.stringify(body),
605 });
606 const j = await r.json().catch(() => ({}));
607 if (r.status === 401) {
608 result.className = 'join-result err';
609 result.textContent = 'Sign in first — /join needs an authenticated '
610 + 'session (same one Nunba uses). Visit the Nunba panel and '
611 + 'retry here.';
612 } else if (r.ok && j.data && j.data.ok) {
613 result.className = 'join-result ok';
614 if (j.data.already_registered) {
615 result.textContent =
616 `Already registered on track "${j.data.track}". `
617 + 'Every scoring event lands in your wallet automatically.';
618 } else {
619 result.textContent =
620 `Welcome to the contest — track "${j.data.track}". `
621 + 'Your first Spark has been awarded.';
622 }
623 loadLeaderboard();
624 } else {
625 result.className = 'join-result err';
626 result.textContent = (j && j.error) || (j && j.data && j.data.reason)
627 || `Join failed (HTTP ${r.status}).`;
628 }
629 } catch (err) {
630 result.className = 'join-result err';
631 result.textContent = `Network error: ${String(err && err.message || err)}`;
632 } finally {
633 btn.disabled = false;
634 btn.textContent = orig;
635 }
636 });
638 // ─── Ideas wall ──────────────────────────────────────────────────
639 async function loadIdeas() {
640 const box = $('#ideas-box');
641 try {
642 const trackQ = currentFilter ? `?track=${encodeURIComponent(currentFilter)}` : '';
643 const r = await fetch(`/api/hive/contest/ideas${trackQ}&limit=20`
644 .replace('?&', '?'));
645 const j = await r.json();
646 const rows = (j && j.data) || [];
647 const meta = (j && j.meta) || {};
648 $('#ideas-meta').textContent =
649 `${rows.length} idea${rows.length === 1 ? '' : 's'} · `
650 + `${esc(meta.track || 'all')} track`;
651 if (!rows.length) {
652 box.innerHTML =
653 '<div class="empty">No ideas yet — be the first to drop one.</div>';
654 return;
655 }
656 const rowsHtml = rows.map((row) => {
657 const title = esc(row.title || '(untitled)');
658 const preview = esc(row.preview || row.content || '');
659 const track = esc(row.track || '?');
660 const score = Number(row.score || 0);
661 return `
662 <tr>
663 <td>
664 <div style="font-weight:600;color:var(--hart-text);
665 font-size:14px;margin-bottom:4px">${title}</div>
666 <div style="color:var(--hart-muted);font-size:13px;
667 line-height:1.4">${preview}</div>
668 <div style="margin-top:6px">
669 <span class="track-badge"
670 style="padding:2px 8px;border-radius:999px;font-size:10px;
671 background:rgba(108,99,255,0.15);color:var(--hart-accent);
672 text-transform:uppercase;letter-spacing:0.6px">
673 ${track}
674 </span>
675 <span style="margin-left:10px;color:var(--hart-muted);
676 font-size:11px">score ${score}</span>
677 </div>
678 </td>
679 </tr>`;
680 }).join('');
681 box.innerHTML = `<table><tbody>${rowsHtml}</tbody></table>`;
682 } catch (e) {
683 box.innerHTML = '<div class="empty">Ideas unavailable.</div>';
684 }
685 }
687 $('#idea-form').addEventListener('submit', async (e) => {
688 e.preventDefault();
689 const btn = $('#idea-btn');
690 const result = $('#idea-result');
691 result.className = 'join-result';
692 result.style.display = 'none';
693 btn.disabled = true;
694 const orig = btn.textContent;
695 btn.innerHTML = '<span class="spinner"></span> Submitting…';
696 try {
697 const body = {
698 title: $('#idea-title').value.trim(),
699 description: $('#idea-desc').value.trim(),
700 track: $('#idea-track').value,
701 source: 'ui',
702 };
703 if (!body.title || !body.description) {
704 result.className = 'join-result err';
705 result.textContent = 'Title and description are required.';
706 return;
707 }
708 const r = await fetch('/api/hive/contest/ideas', {
709 method: 'POST',
710 headers: {'Content-Type': 'application/json'},
711 credentials: 'include',
712 body: JSON.stringify(body),
713 });
714 const j = await r.json().catch(() => ({}));
715 if (r.status === 401) {
716 result.className = 'join-result err';
717 result.textContent = 'Sign in first — submissions need an '
718 + 'authenticated session. Open Nunba and come back.';
719 } else if (r.status === 201 && j.data && j.data.ok) {
720 result.className = 'join-result ok';
721 result.textContent =
722 `Idea submitted (track "${j.data.track}"). `
723 + `+${j.data.spark_awarded} Spark to your wallet.`;
724 $('#idea-title').value = '';
725 $('#idea-desc').value = '';
726 loadIdeas();
727 loadLeaderboard();
728 } else {
729 result.className = 'join-result err';
730 result.textContent = (j && j.error) || `Submit failed (HTTP ${r.status}).`;
731 }
732 } catch (err) {
733 result.className = 'join-result err';
734 result.textContent = `Network error: ${String(err && err.message || err)}`;
735 } finally {
736 btn.disabled = false;
737 btn.textContent = orig;
738 }
739 });
741 // SSE feed — append newly-submitted ideas in real time so the page
742 // feels live even for the submitter themselves.
743 function subscribeIdeaStream() {
744 try {
745 const es = new EventSource('/api/hive/contest/ideas/stream');
746 es.addEventListener('contest.idea_submitted', () => loadIdeas());
747 es.onerror = () => { /* silent; EventSource auto-reconnects */ };
748 } catch (_e) {}
749 }
750 subscribeIdeaStream();
752 // ─── Boot ────────────────────────────────────────────────────────
753 loadInfo();
754 loadLeaderboard();
755 loadSnippet();
756 loadIdeas();
758 // Refresh the leaderboard every 30s so top-of-mind state stays live.
759 setInterval(loadLeaderboard, 30000);
760})();
761</script>
762</body>
763</html>
764"""
767@hive_contest_ui_bp.route('/hive-contest', methods=['GET'])
768def contest_page():
769 """Serve the contest page. Public — anyone can read the contest
770 rules + leaderboard; joining still requires auth at POST /join."""
771 css = _FALLBACK_CSS
772 html = _HTML.replace('__CSS__', css)
773 return Response(html, mimetype='text/html; charset=utf-8')