@@ -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