Skip to content

Commit 076fff6

Browse files
markets/: fix leaked slip timers, keyboard traps and the lost edits
Round-3 launch gate: don't ship, seven blockers. Four were real bugs in the confirmation slip; three were edits from round 2 that never reached disk -- a python batch threw on its last replacement before writing, so the DOM reorder, toast stacking and position labels were silently discarded while I reported them as done. Every step now writes immediately so a later failure cannot roll back earlier work. The slip leaked a countdown interval on every re-price: one slip, three re-prices, four live timers, three still running after Cancel. Each orphan later fired cancelSlip() on whatever slip happened to be open, so on a busy market -- one timer per tick from other traders -- a slip died about a minute later no matter how often the user pressed Keep this price. That silently un-did both of round 2's fixes. The Keep this price button could not be operated by keyboard, which is the control that exists to satisfy WCAG 2.2.1 in the first place: the countdown rewrote its container's innerHTML every second, destroying and recreating the button, so focus was on BODY a second after reaching it. The countdown is now its own node, built once, with only a span's text changing -- and it survives a re-price, which used to rewrite the whole slip copy the button lived inside. Confirming a bet dumped focus on BODY: cancelSlip() restored focus to the buy button and the next line disabled it. Disabling first didn't fix it either, because focus() on a disabled control is a no-op -- it needs a fallback target. And the fill record was written into the element that quote() clears, so the only evidence of a placed bet was erased by the next re-quote; it now has its own node. Also: every websocket price tick rebuilt the outcome list, so a keyboard user lost their place every few seconds on a live market -- prices now update in place when the market's shape is unchanged. --line-strong measured 2.90:1 rather than the 3.05:1 the comment claimed (1.4.11 needs 3:1), now 3.98:1. Settled rows emitted two class attributes so the lost styling was dropped. 75 plugin tests green.
1 parent fa88312 commit 076fff6

1 file changed

Lines changed: 112 additions & 66 deletions

File tree

markets/ui.js

Lines changed: 112 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -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">&larr; 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

Comments
 (0)