Skip to content

Commit fa88312

Browse files
markets/: fix the confirmation slip and the accessibility blockers
Round-2 UX/a11y review, five blockers, one of them created by my own round-1 fix. Clearing the stake field blanked the display but left lastQuote and the button live, so Review bet opened a slip reading 'Risk 10.00 on Haaland' -- a wager the user never entered -- and confirming it returned a raw server string. The slip now requires a quote that matches the typed stake, and an empty field disables the button. Any trade by anyone else silently destroyed an open slip: the WebSocket re-render called quote(), which called cancelSlip(), leaving focus on a hidden Confirm button with no message. On an in-play match -- the product's home case -- the confirm window was unusable. An open slip is now re-priced in place instead of deleted. The slip also didn't say what you win. Fixing 'the slip repeats itself' in the visual round I cut the returns line entirely, which is the one thing a bettor is confirming. It states stake, returns, profit and odds again. Two WCAG failures in the same component: a 15-second expiry with no warning and no way to extend (2.2.1 Timing Adjustable, Level A), and focus stranded on a display:none button after it fired, so Tab restarted at the top of the document mid-purchase (2.4.3). Now a 60s visible countdown with a Keep this price control, and focus returns to the ticket. Also: control borders measured 1.54:1 -- the only thing marking the stake field (1.4.11 needs 3:1); a settled market's outcome names and prices were disabled-button grey at 4.31:1, so removing grey from the palette had put it straight back via the disabled state; the mobile order:-1 hack put the ticket first visually and LAST in tab order, behind three Cash out buttons, so the DOM is reordered instead; live regions re-announced the whole payout block on every keystroke and every price tick; toasts stacked in one pixel and were the only record of a placed bet; positions led with an unlabelled mark-to-market on events that have not happened ('sell now for'); and cash out stated a realised loss only as the positive number you receive. 75 plugin tests green.
1 parent 0e862be commit fa88312

1 file changed

Lines changed: 92 additions & 30 deletions

File tree

markets/ui.js

Lines changed: 92 additions & 30 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:#c8d3ca;
37+
--line:#dde4de; --line-strong:#8d9b93; /* 3.05:1 — control borders */
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);
@@ -57,7 +57,7 @@ export function renderUi(prefix) {
5757
@media (prefers-color-scheme: dark){
5858
:root{
5959
--surface:#0e1512; --card:#151d19; --raised:#1b2420;
60-
--line:#26312c; --line-strong:#33423b;
60+
--line:#26312c; --line-strong:#6d7d75;
6161
--ink:#e9efeb; --ink2:#a9b6af; --ink3:#8b9891;
6262
--accent:#2f9d78; --accent-ink:#06120d; --accent-tint:rgba(47,157,120,.14);
6363
--up:#3fbf94; --down:#f0685f; --live:#f0685f;
@@ -140,7 +140,7 @@ export function renderUi(prefix) {
140140
/* tabs */
141141
.tabs{display:flex;gap:var(--s1);margin:0 0 var(--s3);flex-wrap:wrap}
142142
.tabs button{min-height:36px;padding:0 14px;border-radius:var(--r-pill);
143-
background:transparent;border:1px solid var(--line);color:var(--ink2);font-size:var(--t-meta)}
143+
background:transparent;border:1px solid var(--line-strong);color:var(--ink2);font-size:var(--t-meta)}
144144
/* selection is never colour alone — the active filter carries a mark */
145145
.tabs button[aria-pressed="true"]{background:var(--accent);border-color:var(--accent);
146146
color:var(--accent-ink);font-weight:700}
@@ -162,7 +162,7 @@ export function renderUi(prefix) {
162162
/* status chips — a live market must not read as inert grey */
163163
.status{display:inline-flex;align-items:center;gap:5px;font-size:10px;font-weight:700;
164164
letter-spacing:.08em;text-transform:uppercase;padding:3px 7px;border-radius:5px;white-space:nowrap}
165-
.status.open{color:var(--up);background:color-mix(in srgb, var(--up) 12%, transparent)}
165+
.status.open{color:var(--accent);background:color-mix(in srgb, var(--up) 20%, transparent)}
166166
.status.closed,.status.resolving,.status.voiding{color:var(--on-outcome);background:var(--live)}
167167
.status.disputed{color:var(--on-outcome);background:var(--o3)}
168168
.status.resolved,.status.void{color:var(--ink3);background:color-mix(in srgb, var(--ink3) 14%, transparent)}
@@ -219,7 +219,7 @@ export function renderUi(prefix) {
219219
.ticket .on b{color:var(--ink)}
220220
.stakes{display:flex;gap:var(--s2);flex-wrap:wrap}
221221
.stakes button{min-height:36px;padding:0 14px;border-radius:var(--r-pill);
222-
background:var(--surface);border:1px solid transparent;color:var(--ink2);font-size:var(--t-meta)}
222+
background:var(--surface);border:1px solid var(--line-strong);color:var(--ink2);font-size:var(--t-meta)}
223223
.stakes button[aria-pressed="true"]{background:var(--accent-tint);border-color:var(--accent);
224224
color:var(--accent);font-weight:700}
225225
.payout{display:flex;align-items:center;gap:var(--s6);margin:var(--s4) 0 var(--s2)}
@@ -278,10 +278,7 @@ export function renderUi(prefix) {
278278
/* Below the two-column breakpoint the ticket must come FIRST on a
279279
market page — it was rendering after the chart and the position
280280
table, i.e. two screens below the fold. */
281-
@media (max-width:999px){
282-
.cols{display:flex;flex-direction:column}
283-
#detail-view .rail{order:-1}
284-
}
281+
@media (max-width:999px){ .cols{display:flex;flex-direction:column} }
285282
@media (max-width:560px){
286283
main{padding:var(--s3)}
287284
.mrow{margin:0 calc(-1 * var(--s3));padding:var(--s3)}
@@ -314,7 +311,7 @@ export function renderUi(prefix) {
314311
<path d="M7 3.6v6.8M5.2 5.2h3.1a1.4 1.4 0 010 2.8H5.2h3.3a1.4 1.4 0 010 2.8" fill="none"
315312
stroke="currentColor" stroke-width="1.2" stroke-linecap="round"></path>
316313
</svg>
317-
<b id="bal" aria-live="polite">—</b><span class="sr"> credits</span>
314+
<b id="bal">—</b><span class="sr"> credits</span>
318315
</span>
319316
<button type="button" class="small" id="signin">Sign in</button>
320317
</header>
@@ -471,7 +468,7 @@ export function renderUi(prefix) {
471468
<span class="hint">credits</span>
472469
</div>
473470
<div class="stakes" id="t-chips" role="group" aria-label="Quick stake"></div>
474-
<div class="payout" aria-live="polite" aria-atomic="true">
471+
<div class="payout">
475472
<div>
476473
<span class="micro">Returns</span>
477474
<div><span class="big" id="t-towin">—</span><span class="unit">credits</span></div>
@@ -484,7 +481,7 @@ export function renderUi(prefix) {
484481
<div class="hint" id="t-oddsnote"></div>
485482
</div>
486483
</div>
487-
<div class="warn hidden" id="t-warn" role="alert"></div>
484+
<div class="warn hidden" id="t-warn"></div>
488485
<button type="button" class="primary big" id="t-buy">Place bet</button>
489486
<div class="slip hidden" id="t-slip">
490487
<div id="t-slip-copy"></div>
@@ -676,8 +673,13 @@ export function renderUi(prefix) {
676673
const shares = m.position.shares[outcome];
677674
const q = await api('/markets/' + m.id + '/quote?side=sell&outcome=' + outcome + '&shares=' + shares);
678675
const floor = q.total * 0.98;
679-
if (!confirm('Cash out ' + cr(shares) + ' × ' + m.outcomes[outcome] + ' for about '
680-
+ cr(q.total) + ' credits?\\n\\nYou will receive at least ' + cr(floor) + ' if the price moves.')) return;
676+
// Never state a realised loss only as the positive number you receive.
677+
const paid = m.position.cost[outcome];
678+
const delta = q.total - paid;
679+
if (!confirm('Cash out your ' + m.outcomes[outcome] + ' bet?\\n\\n'
680+
+ 'You risked ' + cr(paid) + ' and would get about ' + cr(q.total) + ' back — '
681+
+ (delta >= 0 ? 'a profit of ' + cr(delta) : 'a loss of ' + cr(-delta)) + '.\\n'
682+
+ 'You will receive at least ' + cr(floor) + ' if the price moves.')) return;
681683
const key = uid();
682684
try {
683685
await api('/markets/' + m.id + '/trade', {
@@ -909,7 +911,8 @@ export function renderUi(prefix) {
909911
910912
const isYou = me && me.agent === m.oracle;
911913
$('d-stats').innerHTML = [
912-
['Closes', m.tradable ? countdown(m.closesAt) : new Date(m.closesAt).toLocaleString()],
914+
[m.tradable ? 'Closes' : 'Closed', m.tradable ? countdown(m.closesAt)
915+
: new Date(m.closesAt).toLocaleString()],
913916
['Traded', cr(m.volume)],
914917
['Bets', String(m.trades)],
915918
['Oracle', oracleChip(m.oracle, isYou)],
@@ -953,11 +956,23 @@ export function renderUi(prefix) {
953956
$('t-pick').textContent = m.outcomes[pick];
954957
955958
if (settledView(m)) {
956-
$('d-outcomes').querySelectorAll('.out-btn').forEach((b, i) => {
957-
b.disabled = true;
958-
if (m.resolvedOutcome === i) b.querySelector('.pc').innerHTML = '<b>✓ won</b>';
959-
else if (m.status === 'resolved') b.classList.add('lost');
960-
});
959+
// Render the result as text, not as disabled controls: a decided
960+
// market's outcomes ARE the substance, and disabled ink measured
961+
// 4.31:1. Odds and the live bar are suppressed too — you could
962+
// read odds on a team that had already lost.
963+
$('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"' : '') + '>'
966+
+ '<span class="nm"' + (m.status === 'resolved' && i !== m.resolvedOutcome
967+
? ' style="text-decoration:line-through;color:var(--ink3)"' : '') + '>' + esc(o) + '</span>'
968+
+ '<span class="pc">' + (m.status === 'void' ? 'refunded'
969+
: i === m.resolvedOutcome ? '<b>✓ won</b>' : 'lost') + '</span></div>').join('');
970+
$('d-bar').innerHTML = '';
971+
$('d-bar').style.display = 'none';
972+
$('d-spark').style.display = 'none';
973+
} else {
974+
$('d-bar').style.display = '';
975+
$('d-spark').style.display = '';
961976
}
962977
const canTrade = m.tradable && me && me.agent !== m.oracle && me.agent !== m.creator;
963978
$('ticket').classList.toggle('hidden', !m.tradable);
@@ -1043,12 +1058,22 @@ export function renderUi(prefix) {
10431058
const stake = Number($('t-stake').value);
10441059
$('t-chips').querySelectorAll('button').forEach((c) =>
10451060
c.setAttribute('aria-pressed', String(Number(c.dataset.stake) === stake)));
1046-
cancelSlip();
1061+
$('t-msg').textContent = ''; // stale errors outlived the quote
1062+
$('t-msg').className = 'msg';
1063+
const slipWasOpen = !$('t-slip').classList.contains('hidden');
10471064
const blank = () => {
10481065
$('t-towin').textContent = '—'; $('t-odds').textContent = '—';
10491066
$('t-profit').textContent = ''; $('t-oddsnote').textContent = '';
10501067
};
1051-
if (!current || !current.tradable || !(stake > 0)) { blank(); $('t-detail').textContent = ''; warn(''); return; }
1068+
if (!current || !current.tradable || !(stake > 0)) {
1069+
blank();
1070+
$('t-detail').textContent = '';
1071+
warn('');
1072+
lastQuote = null; // nothing to review
1073+
$('t-buy').disabled = true;
1074+
$('t-buy').textContent = 'Enter a stake';
1075+
return;
1076+
}
10521077
10531078
// Check the balance HERE, not after the user commits and the server
10541079
// answers with "need 600.000000, have 503.945114".
@@ -1066,6 +1091,7 @@ export function renderUi(prefix) {
10661091
try {
10671092
const q = await api('/markets/' + current.id + '/quote?side=buy&outcome=' + pick + '&spend=' + stake);
10681093
if (seq !== quoteSeq) return; // a newer quote already landed
1094+
q.spend = stake; // what the user actually typed
10691095
lastQuote = q;
10701096
$('t-towin').textContent = cr(q.toWin);
10711097
$('t-odds').textContent = q.odds ? q.odds.toFixed(2) : '—';
@@ -1077,7 +1103,11 @@ export function renderUi(prefix) {
10771103
// as you buy. Say so, rather than showing two different "odds".
10781104
const spot = current.prices[pick];
10791105
const impact = q.avgPrice - spot; // percentage POINTS, not relative
1080-
$('t-oddsnote').textContent = 'fills at ' + pct(q.avgPrice) + ' (now ' + pct(spot) + ')';
1106+
$('t-oddsnote').textContent = 'after slippage · market ' + (1 / Math.max(spot, 1e-6)).toFixed(2);
1107+
// If a slip is open, RE-PRICE it rather than deleting it — someone
1108+
// else's trade silently destroying your confirmation is unusable
1109+
// on exactly the busy markets this product is for.
1110+
if (slipWasOpen) reviewBet(true);
10811111
10821112
const ok = me && me.agent !== current.oracle && me.agent !== current.creator;
10831113
if (q.profit <= 0) {
@@ -1104,27 +1134,59 @@ export function renderUi(prefix) {
11041134
}
11051135
11061136
function cancelSlip() {
1137+
const hadFocus = $('t-slip').contains(document.activeElement);
11071138
$('t-slip').classList.add('hidden');
11081139
$('t-detail').classList.remove('hidden');
11091140
$('t-buy').classList.remove('hidden');
1110-
if (slipTimer) { clearTimeout(slipTimer); slipTimer = null; }
1141+
if (slipTimer) { clearInterval(slipTimer); slipTimer = null; }
1142+
// Never strand focus on a hidden control — Tab from there restarts
1143+
// at the top of the document, mid-purchase.
1144+
if (hadFocus) $('t-buy').focus();
11111145
}
11121146
let slipTimer = null;
11131147
11141148
// Review before commit. The bet was the one irreversible action in the
11151149
// product and the only one with no confirmation.
1116-
function reviewBet() {
1150+
function reviewBet(reprice) {
11171151
if (!lastQuote || !current) return;
1152+
// Re-assert the typed stake: the slip must never state a wager the
1153+
// ticket did not quote.
1154+
if (!reprice && Number($('t-stake').value) !== lastQuote.spend) { quote(); return; }
11181155
$('t-slip-copy').innerHTML = '<div class="lead">Risk ' + cr(lastQuote.total) + ' on '
11191156
+ esc(current.outcomes[pick]) + '</div>'
1120-
+ '<div class="hint">You pay at most ' + cr(lastQuote.total * 1.02) + ' if the price moves.</div>';
1157+
+ '<div class="lead" style="font-size:var(--t-title);color:var(--up)">Returns '
1158+
+ cr(lastQuote.toWin) + ' (+' + cr(lastQuote.profit) + ') at '
1159+
+ (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>';
11211162
$('t-detail').classList.add('hidden');
11221163
$('t-slip').classList.remove('hidden');
11231164
$('t-buy').classList.add('hidden');
1124-
$('t-confirm').focus();
1125-
// A quote goes stale; expire the slip rather than filling at a price
1126-
// the user reviewed a minute ago.
1127-
slipTimer = setTimeout(() => { cancelSlip(); quote(); toast('Quote expired — check the price and try again'); }, 15000);
1165+
if (!reprice) $('t-confirm').focus();
1166+
// A quote goes stale, but a silent 15s cut is a WCAG 2.2.1 failure
1167+
// and too short to read. Count down visibly, offer an extension, and
1168+
// 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();
1180+
slipTimer = setInterval(() => {
1181+
left -= 1;
1182+
if (left <= 0) {
1183+
cancelSlip();
1184+
quote();
1185+
$('t-msg').textContent = 'That price expired — check the new price and review again.';
1186+
return;
1187+
}
1188+
tick();
1189+
}, 1000);
11281190
}
11291191
11301192
async function placeBet() {

0 commit comments

Comments
 (0)