@@ -34,7 +34,7 @@ export function renderUi(prefix) {
3434 :root{
3535 /* surfaces + ink */
3636 --surface:#eef1ee; --card:#ffffff; --raised:#ffffff;
37- --line:#dde4de; --line-strong:#8d9b93 ; /* 3.05 :1 — control borders */
37+ --line:#dde4de; --line-strong:#74837b ; /* measured 3.98 :1 on white, 3.50:1 on surface */
3838 --ink:#111815; --ink2:#4a564f; --ink3:#5e6b64;
3939 /* brand + semantics. up/down are P&L ONLY, never a button. */
4040 --accent:#0b6b4f; --accent-ink:#ffffff; --accent-tint:rgba(11,107,79,.08);
@@ -111,6 +111,12 @@ export function renderUi(prefix) {
111111 @media (min-width:1000px){
112112 .cols{display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:var(--s6);align-items:start}
113113 .rail{position:sticky;top:calc(56px + var(--s4))}
114+ /* The ticket is FIRST in the DOM so that on a phone the primary CTA
115+ is both on screen and next in tab order. Desktop puts it back in
116+ column two with grid order — doing it visually only would break
117+ WCAG 1.3.2 again. */
118+ #detail-view .cols>div:not(.rail){order:1}
119+ #detail-view .rail{order:2}
114120 }
115121 .card{background:var(--card);border:1px solid var(--line);border-radius:var(--r-card);
116122 box-shadow:var(--shadow);padding:var(--s4);margin:0 0 var(--s4)}
@@ -262,8 +268,9 @@ export function renderUi(prefix) {
262268 .msg.err{color:var(--down)}
263269 .hidden{display:none !important}
264270 .empty{color:var(--ink3);font-size:var(--t-meta);padding:var(--s6) 0;text-align:center}
265- .toast{position:fixed;left:50%;transform:translateX(-50%);bottom:var(--s6);z-index:60;
266- background:var(--ink);color:var(--surface);padding:12px 16px;border-radius:var(--r-card);
271+ .toasts{position:fixed;left:50%;transform:translateX(-50%);bottom:var(--s6);z-index:60;
272+ display:flex;flex-direction:column-reverse;gap:var(--s2);align-items:center}
273+ .toast{background:var(--ink);color:var(--surface);padding:12px 16px;border-radius:var(--r-card);
267274 font-size:var(--t-body);box-shadow:var(--shadow-lift);max-width:92vw}
268275 details>summary{list-style:none}
269276 details>summary::-webkit-details-marker{display:none}
@@ -293,6 +300,7 @@ export function renderUi(prefix) {
293300</head>
294301<body>
295302<a class="skip" href="#main">Skip to markets</a>
303+ <div class="toasts" id="toasts"></div>
296304<header class="topbar">
297305 <a class="brand" href="${ prefix } ">
298306 <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">
@@ -421,40 +429,6 @@ export function renderUi(prefix) {
421429 <div id="detail-view" class="hidden">
422430 <p><a href="#" id="back">← All markets</a></p>
423431 <div class="cols">
424- <div>
425- <div class="card">
426- <div class="row" style="gap:var(--s2)"><span id="d-status"></span></div>
427- <h1 id="d-title" tabindex="-1"></h1>
428- <p class="hint" id="d-rules"></p>
429- <div class="stats" id="d-stats"></div>
430- <div class="bar" id="d-bar" role="img" aria-label="Current prices"></div>
431- <svg class="spark" id="d-spark" viewBox="0 0 320 140" role="img"
432- aria-labelledby="spark-title"><title id="spark-title">Price history</title></svg>
433- <div id="d-outcomes" role="group" aria-label="Choose an outcome"></div>
434- </div>
435-
436- <div class="card hidden" id="d-position"></div>
437-
438- <div class="card hidden" id="oracle-card">
439- <h2>Oracle controls</h2>
440- <div class="row">
441- <label class="sr" for="o-outcome">Winning outcome</label>
442- <select id="o-outcome" style="flex:1;min-width:140px"></select>
443- <button type="button" id="o-resolve">Resolve</button>
444- </div>
445- <div class="row">
446- <button type="button" id="o-close">Close early</button>
447- <button type="button" id="o-void">Propose void</button>
448- </div>
449- </div>
450-
451- <div class="card hidden" id="dispute-card">
452- <h2>Does this look wrong?</h2>
453- <p class="hint" id="dispute-copy"></p>
454- <button type="button" id="o-dispute">Dispute this outcome</button>
455- </div>
456- </div>
457-
458432 <aside class="rail">
459433 <div class="ticket" id="ticket">
460434 <div class="row" style="justify-content:space-between">
@@ -485,15 +459,51 @@ export function renderUi(prefix) {
485459 <button type="button" class="primary big" id="t-buy">Place bet</button>
486460 <div class="slip hidden" id="t-slip">
487461 <div id="t-slip-copy"></div>
462+ <div class="hint" id="t-countdown"></div>
488463 <div class="row">
489464 <button type="button" class="primary big" id="t-confirm" style="flex:2">Confirm bet</button>
490465 <button type="button" class="big ghost" id="t-cancel" style="flex:1">Cancel</button>
491466 </div>
492467 </div>
493468 <div class="breakdown" id="t-detail"></div>
469+ <div class="msg" id="t-fill"></div>
494470 <div class="msg" id="t-msg" role="alert"></div>
495471 </div>
496472 </aside>
473+ <div>
474+ <div class="card">
475+ <div class="row" style="gap:var(--s2)"><span id="d-status"></span></div>
476+ <h1 id="d-title" tabindex="-1"></h1>
477+ <p class="hint" id="d-rules"></p>
478+ <div class="stats" id="d-stats"></div>
479+ <div class="bar" id="d-bar" role="img" aria-label="Current prices"></div>
480+ <svg class="spark" id="d-spark" viewBox="0 0 320 140" role="img"
481+ aria-labelledby="spark-title"><title id="spark-title">Price history</title></svg>
482+ <div id="d-outcomes" role="group" aria-label="Choose an outcome"></div>
483+ </div>
484+
485+ <div class="card hidden" id="d-position"></div>
486+
487+ <div class="card hidden" id="oracle-card">
488+ <h2>Oracle controls</h2>
489+ <div class="row">
490+ <label class="sr" for="o-outcome">Winning outcome</label>
491+ <select id="o-outcome" style="flex:1;min-width:140px"></select>
492+ <button type="button" id="o-resolve">Resolve</button>
493+ </div>
494+ <div class="row">
495+ <button type="button" id="o-close">Close early</button>
496+ <button type="button" id="o-void">Propose void</button>
497+ </div>
498+ </div>
499+
500+ <div class="card hidden" id="dispute-card">
501+ <h2>Does this look wrong?</h2>
502+ <p class="hint" id="dispute-copy"></p>
503+ <button type="button" id="o-dispute">Dispute this outcome</button>
504+ </div>
505+ </div>
506+
497507 </div>
498508 </div>
499509</main>
@@ -536,7 +546,7 @@ export function renderUi(prefix) {
536546 const el = document.createElement('div');
537547 el.className = 'toast'; el.textContent = text;
538548 el.setAttribute('role', 'status'); el.setAttribute('aria-live', 'polite');
539- document.body. appendChild(el);
549+ $('toasts'). appendChild(el); // stack, don't pile in one pixel
540550 setTimeout(() => el.remove(), ms);
541551 }
542552
@@ -632,7 +642,7 @@ export function renderUi(prefix) {
632642 const totalVal = me.positions.reduce((a, p) => a + p.totalValue, 0);
633643 const totalPnl = me.positions.reduce((a, p) => a + p.unrealizedPnl, 0);
634644 el.innerHTML = '<div class="row" style="justify-content:space-between;align-items:baseline">'
635- + '<span><span class="micro">Open value </span> <b style="font-size:var(--t-title)">' + cr(totalVal) + '</b></span>'
645+ + '<span><span class="micro">Sell all now for </span> <b style="font-size:var(--t-title)">' + cr(totalVal) + '</b></span>'
636646 + '<span class="pnl ' + (totalPnl >= 0 ? 'up' : 'down') + '">'
637647 + (totalPnl >= 0 ? '+' : '') + cr(totalPnl) + '</span></div>'
638648 + '<ul class="mlist">' + me.positions.map((p) => {
@@ -644,7 +654,7 @@ export function renderUi(prefix) {
644654 + '<span style="min-width:0"><span class="t" style="font-size:var(--t-meta);font-weight:600">'
645655 + esc(p.title) + '</span>'
646656 + '<div class="meta" style="margin-top:2px">' + esc(on.join(', '))
647- + '<span class="dot">·</span>worth ' + cr(p.totalValue) + '</div></span>'
657+ + '<span class="dot">·</span>sell now for ' + cr(p.totalValue) + '</div></span>'
648658 + '<span class="pnl ' + cls + '" style="white-space:nowrap">' + sign + cr(p.unrealizedPnl)
649659 + (p.totalCost > 0 ? '<div class="hint" style="text-align:right">' + sign
650660 + (p.unrealizedPnl / p.totalCost * 100).toFixed(0) + '%</div>' : '')
@@ -894,7 +904,7 @@ export function renderUi(prefix) {
894904 return;
895905 }
896906 if (seq !== detailSeq) return; // a newer render already landed
897- if (!current || current.id !== m.id) { pick = 0; newIntent(); }
907+ if (!current || current.id !== m.id) { pick = 0; newIntent(); $('t-fill').textContent = ''; }
898908 current = m;
899909 $('list-view').classList.add('hidden');
900910 $('detail-view').classList.remove('hidden');
@@ -929,16 +939,29 @@ export function renderUi(prefix) {
929939
930940 if (pick >= m.outcomes.length) pick = 0;
931941 const held = m.position ? m.position.shares : m.outcomes.map(() => 0);
932- $('d-outcomes').innerHTML = m.outcomes.map((o, i) =>
933- '<button type="button" class="out-btn" data-i="' + i + '" aria-pressed="' + (i === pick) + '" style="--oc:' + col(i) + '">'
942+ // Update prices IN PLACE when the market's shape hasn't changed.
943+ // Rebuilding this container on every price tick threw keyboard focus
944+ // to BODY every few seconds on a live market.
945+ const existing = $('d-outcomes').querySelectorAll('.out-btn');
946+ const reuse = !settledView(m) && existing.length === m.outcomes.length
947+ && existing[0].dataset.o === m.outcomes[0];
948+ if (reuse) {
949+ existing.forEach((b, i) => {
950+ b.querySelector('.pc').textContent = pct(m.prices[i]);
951+ b.querySelector('.od').firstChild.nodeValue = (1 / Math.max(m.prices[i], 1e-6)).toFixed(2);
952+ b.setAttribute('aria-pressed', String(i === pick));
953+ });
954+ } else $('d-outcomes').innerHTML = m.outcomes.map((o, i) =>
955+ '<button type="button" class="out-btn" data-i="' + i + '" data-o="' + esc(o) + '"'
956+ + ' aria-pressed="' + (i === pick) + '" style="--oc:' + col(i) + '">'
934957 + '<span class="nm">' + esc(o)
935958 + (held[i] > 0 ? '<div class="meta" style="margin:0">you hold ' + cr(held[i]) + ' to win</div>' : '')
936959 + '</span>'
937960 + '<span class="pc">' + pct(m.prices[i]) + '</span>'
938961 + '<span class="od">' + (1 / Math.max(m.prices[i], 1e-6)).toFixed(2)
939962 + '<span class="sr"> decimal odds</span></span>'
940963 + '</button>').join('');
941- $('d-outcomes').querySelectorAll('.out-btn').forEach((b) => {
964+ if (!reuse) $('d-outcomes').querySelectorAll('.out-btn').forEach((b) => {
942965 b.onclick = () => {
943966 // Toggle in place. Rebuilding the container threw keyboard focus
944967 // to the top of the document and re-fetched the whole market
@@ -961,8 +984,9 @@ export function renderUi(prefix) {
961984 // 4.31:1. Odds and the live bar are suppressed too — you could
962985 // read odds on a team that had already lost.
963986 $('d-outcomes').innerHTML = m.outcomes.map((o, i) =>
964- '<div class="out-btn" style="--oc:' + col(i) + ';cursor:default"'
965- + (m.status === 'resolved' && i !== m.resolvedOutcome ? ' class="lost"' : '') + '>'
987+ '<div class="out-btn'
988+ + (m.status === 'resolved' && i !== m.resolvedOutcome ? ' lost' : '')
989+ + '" style="--oc:' + col(i) + ';cursor:default;min-height:44px">'
966990 + '<span class="nm"' + (m.status === 'resolved' && i !== m.resolvedOutcome
967991 ? ' style="text-decoration:line-through;color:var(--ink3)"' : '') + '>' + esc(o) + '</span>'
968992 + '<span class="pc">' + (m.status === 'void' ? 'refunded'
@@ -992,7 +1016,8 @@ export function renderUi(prefix) {
9921016 const returned = receipt ? receipt.payout : null;
9931017 const net = receipt ? (receipt.net === undefined ? receipt.payout : receipt.net) : null;
9941018 pd.innerHTML = '<h2>Your bet</h2>'
995- + '<table><tbody>' + pos.shares.map((s, i) => (s > 0
1019+ + '<table><thead><tr><th>Bet</th><th class="num">Sell now for</th><th class="num"></th></tr></thead>'
1020+ + '<tbody>' + pos.shares.map((s, i) => (s > 0
9961021 ? '<tr><td class="' + (m.status === 'resolved' && i !== won ? 'lost' : '') + '">'
9971022 + '<i style="display:inline-block;width:8px;height:8px;border-radius:2px;background:'
9981023 + col(i) + '"></i> ' + esc(m.outcomes[i]) + ' — risked ' + cr(pos.cost[i]) + '</td>'
@@ -1139,11 +1164,18 @@ export function renderUi(prefix) {
11391164 $('t-detail').classList.remove('hidden');
11401165 $('t-buy').classList.remove('hidden');
11411166 if (slipTimer) { clearInterval(slipTimer); slipTimer = null; }
1167+ $('t-countdown').innerHTML = ''; // so the next slip rebuilds it
11421168 // Never strand focus on a hidden control — Tab from there restarts
11431169 // at the top of the document, mid-purchase.
1144- if (hadFocus) $('t-buy').focus();
1170+ if (hadFocus) {
1171+ // .focus() on a disabled control is a no-op, which left focus on
1172+ // the hidden Confirm button.
1173+ const target = $('t-buy').disabled ? $('t-stake') : $('t-buy');
1174+ target.focus();
1175+ }
11451176 }
11461177 let slipTimer = null;
1178+ let slipLeft = 0;
11471179
11481180 // Review before commit. The bet was the one irreversible action in the
11491181 // product and the only one with no confirmation.
@@ -1157,43 +1189,50 @@ export function renderUi(prefix) {
11571189 + '<div class="lead" style="font-size:var(--t-title);color:var(--up)">Returns '
11581190 + cr(lastQuote.toWin) + ' (+' + cr(lastQuote.profit) + ') at '
11591191 + (lastQuote.odds || 0).toFixed(2) + '</div>'
1160- + '<div class="hint">You pay at most ' + cr(lastQuote.total * 1.02) + ' if the price moves.'
1161- + ' <span id="t-countdown"></span></div>';
1192+ + '<div class="hint">You pay at most ' + cr(lastQuote.total * 1.02) + ' if the price moves.</div>';
11621193 $('t-detail').classList.add('hidden');
11631194 $('t-slip').classList.remove('hidden');
11641195 $('t-buy').classList.add('hidden');
11651196 if (!reprice) $('t-confirm').focus();
1197+ // ONE timer per slip. Re-pricing must not start a second one.
1198+ if (slipTimer) { clearInterval(slipTimer); slipTimer = null; }
11661199 // A quote goes stale, but a silent 15s cut is a WCAG 2.2.1 failure
11671200 // and too short to read. Count down visibly, offer an extension, and
11681201 // never expire while the user is still inside the slip.
1169- let left = 60;
1170- const tick = () => {
1171- const el = $('t-countdown');
1172- if (!el) return;
1173- el.innerHTML = left > 0
1174- ? 'This price holds for <b>' + left + 's</b>. <button type="button" class="small" id="t-extend">Keep this price</button>'
1175- : '';
1176- const ext = $('t-extend');
1177- if (ext) ext.onclick = () => { left = 60; tick(); };
1178- };
1179- tick();
1202+ // A re-price keeps the user's remaining time rather than silently
1203+ // granting a fresh 60s on someone else's trade.
1204+ if (!reprice || !(slipLeft > 0)) slipLeft = 60;
1205+ // Build the countdown ONCE: rewriting this container's innerHTML on
1206+ // every tick destroyed and recreated the button, so focus landed on
1207+ // BODY a second after you reached it.
1208+ // Build it ONCE per slip. Re-pricing must not destroy a control the
1209+ // user may be focused on.
1210+ if (!$('t-extend')) {
1211+ $('t-countdown').innerHTML = 'This price holds for <b><span id="t-left"></span>s</b>. '
1212+ + '<button type="button" class="small" id="t-extend">Keep this price</button>';
1213+ $('t-extend').onclick = () => { slipLeft = 60; $('t-left').textContent = slipLeft; };
1214+ }
1215+ const paint = () => { const el = $('t-left'); if (el) el.textContent = slipLeft; };
1216+ paint();
11801217 slipTimer = setInterval(() => {
1181- left -= 1;
1182- if (left <= 0) {
1218+ slipLeft -= 1;
1219+ if (slipLeft <= 0) {
11831220 cancelSlip();
11841221 quote();
11851222 $('t-msg').textContent = 'That price expired — check the new price and review again.';
11861223 return;
11871224 }
1188- tick ();
1225+ paint ();
11891226 }, 1000);
11901227 }
11911228
11921229 async function placeBet() {
11931230 if (!lastQuote) return;
1194- cancelSlip();
1231+ // Disable BEFORE restoring focus: doing it after blew focus off the
1232+ // element cancelSlip() had just focused, landing on BODY.
11951233 $('t-msg').textContent = ''; $('t-msg').className = 'msg';
11961234 $('t-buy').disabled = true;
1235+ cancelSlip();
11971236 try {
11981237 if (!betKey) betKey = uid(); // one key per INTENT, so a retry replays
11991238 const r = await api('/markets/' + current.id + '/trade', {
@@ -1202,7 +1241,11 @@ export function renderUi(prefix) {
12021241 body: JSON.stringify({ side: 'buy', outcome: pick, spend: Number($('t-stake').value), maxCost: lastQuote.total * 1.02 }),
12031242 });
12041243 betKey = null;
1205- toast('Bet placed — ' + cr(r.shares) + ' × ' + r.outcomeLabel + ', returns ' + cr(r.toWin));
1244+ toast('Bet placed — ' + cr(r.total) + ' on ' + r.outcomeLabel + ', returns ' + cr(r.toWin));
1245+ // A persistent record on the page: a 4.5s toast that a settlement
1246+ // toast can paint over is not a receipt.
1247+ $('t-fill').innerHTML = '<b>Bet placed.</b> ' + cr(r.total) + ' on ' + esc(r.outcomeLabel)
1248+ + ' → returns ' + cr(r.toWin) + '. Balance ' + cr(r.balance) + '.';
12061249 await refreshMe(); await renderDetail(current.id, true);
12071250 } catch (e) {
12081251 $('t-msg').textContent = e.message + (e.status === 409 ? ' — refresh the quote and try again' : '');
@@ -1213,6 +1256,7 @@ export function renderUi(prefix) {
12131256 const ok = current && current.tradable && me
12141257 && me.agent !== current.oracle && me.agent !== current.creator;
12151258 $('t-buy').disabled = !ok;
1259+ if (ok && document.activeElement === document.body) $('t-buy').focus();
12161260 }
12171261 }
12181262
@@ -1232,6 +1276,8 @@ export function renderUi(prefix) {
12321276 $('list-view').classList.remove('hidden');
12331277 document.title = 'Markets — prediction markets on your pod';
12341278 current = null; cursor = null; paged = false;
1279+ $('t-msg').textContent = ''; $('t-msg').className = 'msg';
1280+ $('t-fill').textContent = '';
12351281 renderTabs();
12361282 return renderList();
12371283 }
0 commit comments