2222// - --up/--down never colour a button, only a number;
2323// - one left edge: the gutter lives on `main`, children sit flush.
2424
25- export function renderUi ( prefix ) {
25+ export function renderUi ( prefix , opts = { } ) {
26+ const accounts = ! ! opts . accounts ; // standalone host: register/login form
2627 const P = JSON . stringify ( prefix ) ;
2728 return `<!doctype html>
2829<html lang="en">
@@ -296,6 +297,13 @@ export function renderUi(prefix) {
296297 .brand span{display:none}
297298 .stats{grid-template-columns:repeat(2,1fr)}
298299 }
300+ .board{list-style:none;margin:0;padding:0}
301+ .board li{display:flex;align-items:baseline;gap:var(--s3);padding:var(--s2) 0;border-bottom:1px solid var(--line);font-size:var(--f-sm)}
302+ .board li:last-child{border-bottom:0}
303+ .board li.you .board-name{font-weight:700;color:var(--accent,inherit)}
304+ .board-rank{color:var(--ink-3);font-variant-numeric:tabular-nums;min-width:1.4em;text-align:right}
305+ .board-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
306+ .board-pl{font-variant-numeric:tabular-nums;font-weight:600}
299307</style>
300308</head>
301309<body>
@@ -327,6 +335,17 @@ export function renderUi(prefix) {
327335<main id="main">
328336 <div class="card hidden" id="auth-card">
329337 <h2>Start with 1,000 free paper credits</h2>
338+ ${ accounts ? `
339+ <p class="hint">Pick a username, get 1,000 paper credits, and start calling the news. Your
340+ session is an HttpOnly cookie scoped to this app; nothing is stored in the browser.</p>
341+ <div class="row">
342+ <label class="sr" for="acct-user">Username</label>
343+ <input id="acct-user" autocomplete="username" placeholder="Username" style="flex:1;min-width:130px">
344+ <label class="sr" for="acct-pass">Password</label>
345+ <input id="acct-pass" type="password" autocomplete="current-password" placeholder="Password" style="flex:1;min-width:130px">
346+ <button type="button" class="primary" id="do-acct-signin">Sign in</button>
347+ <button type="button" id="do-acct-register">Create account</button>
348+ </div>` : `
330349 <p class="hint">New accounts are granted 1,000 paper credits to bet with. Sign in with a pod
331350 bearer token (from <code>POST /idp/credentials</code>) — it is exchanged for a session cookie
332351 scoped to this app and <b>never stored in the browser</b>, so this page cannot leak access to
@@ -335,7 +354,7 @@ export function renderUi(prefix) {
335354 <label class="sr" for="token">Pod bearer token</label>
336355 <input id="token" type="password" placeholder="Pod bearer token" style="flex:1;min-width:200px">
337356 <button type="button" class="primary" id="do-signin">Start session</button>
338- </div>
357+ </div>` }
339358 <div class="msg" id="auth-msg" role="alert"></div>
340359 </div>
341360
@@ -359,6 +378,10 @@ export function renderUi(prefix) {
359378 </div>
360379
361380 <aside class="rail">
381+ <div class="card" id="board-card">
382+ <h2>Top predictors</h2>
383+ <ol id="board" class="board"><li class="empty">No predictions yet.</li></ol>
384+ </div>
362385 <div class="card hidden" id="me-card">
363386 <h2>My positions</h2>
364387 <div id="positions"></div>
@@ -1323,7 +1346,26 @@ export function renderUi(prefix) {
13231346 } catch (e) { $('t-msg').textContent = e.message; $('t-msg').className = 'msg err'; }
13241347 }
13251348
1349+ async function renderBoard() {
1350+ // The endpoint is signed-in-only and pseudonymized by design (a public
1351+ // wealth ranking of identities is recon); signed out, the card is the
1352+ // invitation instead.
1353+ const el = $('board');
1354+ try {
1355+ const { leaderboard } = await api('/leaderboard');
1356+ if (!leaderboard.length) { el.innerHTML = '<li class="empty">No predictions yet.</li>'; return; }
1357+ el.innerHTML = leaderboard.slice(0, 10).map((r) =>
1358+ '<li' + (r.you ? ' class="you"' : '') + '><span class="board-rank">' + r.rank + '</span>'
1359+ + '<span class="board-name">' + (r.you ? 'you' : esc(agentName(r.agent))) + '</span>'
1360+ + '<span class="board-pl">' + r.balance.toFixed(2) + '</span></li>').join('');
1361+ } catch {
1362+ el.innerHTML = '<li class="empty">Sign in to see the top predictors.</li>';
1363+ }
1364+ }
1365+ setInterval(renderBoard, 30000);
1366+
13261367 async function route() {
1368+ renderBoard();
13271369 const h = location.hash;
13281370 if (h.startsWith('#m/')) return renderDetail(h.slice(3), true);
13291371 $('detail-view').classList.add('hidden');
@@ -1358,17 +1400,40 @@ export function renderUi(prefix) {
13581400 if (me) { await api('/session', { method: 'DELETE' }); me = null; await refreshMe(true); await route(); return; }
13591401 $('auth-card').classList.remove('hidden');
13601402 $('auth-card').scrollIntoView({ behavior: 'smooth', block: 'center' });
1361- $('token').focus();
1403+ ( $('token') || $('acct-user') ).focus();
13621404 };
1363- $('do-signin').onclick = async () => {
1364- $('auth-msg').textContent = 'Starting session…'; $('auth-msg').className = 'msg';
1405+ async function startSession(bearer) {
1406+ await api('/session', { method: 'POST', headers: { authorization: 'Bearer ' + bearer } });
1407+ await refreshMe(); await route();
1408+ toast('Signed in');
1409+ }
1410+ if ($('do-signin')) {
1411+ $('do-signin').onclick = async () => {
1412+ $('auth-msg').textContent = 'Starting session…'; $('auth-msg').className = 'msg';
1413+ try {
1414+ await startSession($('token').value.trim());
1415+ $('token').value = '';
1416+ } catch (e) { $('auth-msg').textContent = e.message; $('auth-msg').className = 'msg err'; }
1417+ };
1418+ }
1419+ // Account mode (standalone host): register/login mint a bearer at
1420+ // {prefix}/api/{register,login}, then the normal session exchange runs.
1421+ async function acctAuth(pathName) {
1422+ const username = $('acct-user').value.trim();
1423+ const password = $('acct-pass').value;
1424+ $('auth-msg').textContent = pathName === '/register' ? 'Creating account…' : 'Signing in…';
1425+ $('auth-msg').className = 'msg';
13651426 try {
1366- await api('/session', { method: 'POST', headers: { authorization: 'Bearer ' + $('token').value.trim() } });
1367- $('token').value = '';
1368- await refreshMe(); await route();
1369- toast('Signed in');
1427+ const r = await api(pathName, { method: 'POST', body: JSON.stringify({ username, password }) });
1428+ await startSession(r.token);
1429+ $('acct-pass').value = '';
13701430 } catch (e) { $('auth-msg').textContent = e.message; $('auth-msg').className = 'msg err'; }
1371- };
1431+ }
1432+ if ($('do-acct-signin')) {
1433+ $('do-acct-signin').onclick = () => acctAuth('/login');
1434+ $('do-acct-register').onclick = () => acctAuth('/register');
1435+ $('acct-pass').addEventListener('keydown', (e) => { if (e.key === 'Enter') acctAuth('/login'); });
1436+ }
13721437 $('back').onclick = (e) => { e.preventDefault(); location.hash = ''; };
13731438 $('t-buy').onclick = () => {
13741439 if (!me) {
0 commit comments