PolyTools Journal 0.1.1 / src/content/content.js

481 lines · 24,284 bytes · SHA-256 44cde0312cd41406b7f8916fe0d3e047a0b48eaae51af92ce5be7ab6e0abe0f8

1/*
2 * PolyTools — content script orchestrator on polymarket.com.
3 * Polls the SPA state (URL, ticket DOM) every 600 ms + on user input, resolves the selected market through the
4 * page bridge (React props) with a text heuristic and manual pick as fallbacks, fetches the live book via the
5 * background worker and renders the cost panel.
6 */
7(function () {
8 'use strict';
9 if (window.__pwlContent) return;
10 window.__pwlContent = true;
11
12 const { i18n, fmt, calc, ticket: reader, panel: panelLib } = window.PWL;
13 const F = window.PWL.flavor || { cost: true, journal: true }; // Trade Cost build: panel only; Journal build: profile button only
14 const t = i18n.t;
15 const TAG = 'pwl-lens-v1';
16 const DEFAULT_HOLDING_RATE = 0.0325; // docs.polymarket.com Holding Rewards page, 2026-10-26; the event tag wins when present
17 const BOOK_REFRESH_MS = 4000;
18 const BOOK_REFRESH_FAST_MS = 1500;
19 const BOOK_REFRESH_COLLAPSED_MS = 8000; // markets ending within a day and live games move faster than 4 s
20 const DEFAULTS = { lang: 'auto', panel: true, benchmarkApr: 5, collapsed: false };
21
22 let settings = Object.assign({}, DEFAULTS);
23 let dead = false; // extension reloaded → context invalidated
24
25 const st = {
26 slug: null, marketSlugHint: null, event: null, eventErr: null, eventLoading: false, eventRetryAt: 0,
27 ticketSig: null, conditionId: null, bridgeId: null, bridgeAt: 0,
28 manual: { marketId: null, outcomeIdx: 0, side: 'buy', amount: null, amountRaw: '' }, floatOpen: false,
29 book: null, bookToken: null, bookAt: 0, bookLoading: false, bookErr: null, noBook: false,
30 };
31
32 // The Journal build ships without the panel, the calculator, the ticket reader and the live book: nothing is created there.
33 const panel = F.cost ? panelLib.create({ onAction }) : null;
34 // Live book over the CLOB WebSocket; REST polling below is the fallback while it is not ready.
35 let streamTimer = null, streamAt = 0;
36 const stream = F.cost ? window.PWL.liveBook.create(() => {
37 if (streamTimer) return; // a busy market sends ~30 deltas/s: render at most 4×/s
38 streamTimer = setTimeout(() => { streamTimer = null; streamAt = Date.now(); tick(); }, Math.max(0, 250 - (Date.now() - streamAt)));
39 }) : null;
40 const hideAll = () => { if (panel) panel.hide(); if (stream) stream.close(); };
41 let profileBtn = null;
42
43 // ---------------- messaging ----------------
44 function send(msg) {
45 return new Promise((resolve, reject) => {
46 if (dead) return reject(new Error('dead'));
47 try {
48 chrome.runtime.sendMessage(msg, (res) => {
49 const err = chrome.runtime.lastError;
50 if (err) return reject(new Error(err.message));
51 if (!res) return reject(new Error('no response'));
52 return res.ok ? resolve(res.data) : reject(Object.assign(new Error(res.error), { status: res.status }));
53 });
54 } catch (e) {
55 dead = true; // "Extension context invalidated"
56 reject(e);
57 }
58 });
59 }
60
61 // ---------------- settings ----------------
62 function loadSettings() {
63 return new Promise((resolve) => {
64 const fallback = setTimeout(resolve, 1500); // never block the panel on storage
65 try {
66 chrome.storage.sync.get(DEFAULTS, (s) => {
67 clearTimeout(fallback);
68 if (!chrome.runtime.lastError && s) settings = Object.assign({}, DEFAULTS, s);
69 resolve();
70 });
71 } catch (e) { clearTimeout(fallback); resolve(); }
72 });
73 }
74 try {
75 chrome.storage.onChanged.addListener((changes, area) => {
76 if (area !== 'sync') return;
77 for (const [k, v] of Object.entries(changes)) settings[k] = v.newValue;
78 applyLang();
79 schedule();
80 });
81 } catch (e) { /* ignore */ }
82
83 // ---------------- routing ----------------
84 function route() {
85 const seg = location.pathname.split('/').filter(Boolean).map(decodeURIComponent);
86 // Localized UI lives under a language prefix: /ru/event/…, /es/sports/…, /zh-cn/…
87 if (seg.length > 1 && /^[a-z]{2}(?:-[a-z]{2,4})?$/i.test(seg[0])) seg.shift();
88 if (seg[0] === 'event' && seg[1]) return { kind: 'event', slug: seg[1], marketSlug: seg[2] || null };
89 if ((seg[0] === 'sports' || seg[0] === 'esports') && seg.length >= 3) {
90 const last = seg[seg.length - 1];
91 if (/-/.test(last) && last.length > 5) return { kind: 'event', slug: last, marketSlug: null };
92 }
93 if (seg[0] && seg[0].startsWith('@') && seg[0].length > 1) return { kind: 'profile', user: seg[0] };
94 if (seg[0] === 'profile' && /^0x[0-9a-fA-F]{40}$/.test(seg[1] || '')) return { kind: 'profile', address: seg[1] };
95 return null;
96 }
97
98 // ---------------- event / book loading ----------------
99 async function loadEvent(slug) {
100 st.eventLoading = true;
101 try {
102 const ev = await send({ type: 'event', slug });
103 if (st.slug !== slug) return;
104 st.event = ev; st.eventErr = null;
105 } catch (e) {
106 if (st.slug !== slug) return;
107 st.eventErr = e.status === 404 ? 'not found' : (e.message || 'error');
108 st.eventRetryAt = Date.now() + (e.status === 404 ? 600000 : 8000);
109 } finally {
110 if (st.slug === slug) st.eventLoading = false;
111 schedule();
112 }
113 }
114
115 async function loadBook(tokenId) {
116 st.bookLoading = tokenId; // per token: a slow request for the old token must not block the new one
117 try {
118 const raw = await send({ type: 'book', tokenId });
119 if (st.bookToken !== tokenId) return;
120 st.bookTick = Number(raw && raw.tick_size) || null;
121 st.bookMin = Number(raw && raw.min_order_size) || 0;
122 if (!(stream.ready && stream.token === tokenId)) st.book = calc.normalizeBook(raw);
123 st.bookAt = Date.now(); st.bookErr = null;
124 } catch (e) {
125 if (st.bookToken === tokenId) {
126 st.bookAt = Date.now();
127 // CLOB answers 404 "No orderbook exists" for closed/resolved markets: stop polling that token
128 if (e.status === 404) st.noBook = true; else st.bookErr = e.message || 'error';
129 }
130 } finally {
131 if (st.bookLoading === tokenId) st.bookLoading = false;
132 schedule();
133 }
134 }
135
136 // ---------------- hydration gate ----------------
137 // Next.js hydrates the whole document; inserting nodes into a subtree React has not hydrated yet triggers
138 // "Minified React error #418" and a client re-render. So nothing is written to the page until the ticket is
139 // hydrated (the bridge sees a React fiber on its input) and the page has been idle for a moment. Client-side
140 // navigations later in the SPA need no waiting.
141 // `load` alone is not enough: when one resource hangs (a slow image, a tracker) it never fires and the page stays
142 // "interactive" for minutes (seen on profiles, 29.10). Next.js mounts <next-route-announcer> into <body> right after
143 // the app has hydrated, which the isolated world can see; a long timeout covers a future Next.js without it.
144 const hydration = { ok: false, at: 0, probeId: null, probeAt: 0, loadedAt: 0, annAt: 0, startedAt: Date.now() };
145 window.addEventListener('load', () => { hydration.loadedAt = Date.now(); schedule(); });
146 if (document.readyState === 'complete') hydration.loadedAt = Date.now();
147
148 /** The document is past its initial hydration: load + 3 s, route announcer + 2 s, or 20 s after this script started. */
149 function settled() {
150 const now = Date.now();
151 if (!hydration.annAt && document.querySelector('next-route-announcer')) hydration.annAt = now;
152 return (hydration.loadedAt && now - hydration.loadedAt > 3000) || (hydration.annAt && now - hydration.annAt > 2000)
153 || now - hydration.startedAt > 20000;
154 }
155
156 function pageReady(hasTicketInput) {
157 if (hydration.ok) return Date.now() - hydration.at > 700;
158 if (!hasTicketInput) {
159 // pages without a ticket (profiles, sports listings, narrow windows): no React input to probe, wait for the document
160 if (settled()) { hydration.ok = true; hydration.at = Date.now() - 1000; }
161 return hydration.ok;
162 }
163 if (document.readyState !== 'complete' && !settled()) return false;
164 if (!hydration.probeId || Date.now() - hydration.probeAt > 400) {
165 hydration.probeId = Math.random().toString(36).slice(2);
166 hydration.probeAt = Date.now();
167 window.postMessage({ tag: TAG, dir: 'req', kind: 'probe', id: hydration.probeId }, window.location.origin);
168 }
169 return false;
170 }
171
172 // ---------------- page bridge ----------------
173 window.addEventListener('message', (ev) => {
174 const d = ev.data;
175 if (ev.source !== window || !d || d.tag !== TAG || d.dir !== 'res') return;
176 if (d.id === hydration.probeId) {
177 hydration.probeId = null;
178 if (d.result && d.result.hydrated && !hydration.ok) { hydration.ok = true; hydration.at = Date.now(); setTimeout(schedule, 750); }
179 return;
180 }
181 if (d.id !== st.bridgeId) return;
182 st.bridgeId = null;
183 const c = d.result && d.result.conditionId;
184 const known = st.event && st.event.markets.some((m) => m.conditionId === c);
185 st.conditionId = known ? c : null;
186 schedule();
187 });
188
189 function askBridge() {
190 if (!st.event) return;
191 const ids = st.event.markets.map((m) => m.conditionId).filter(Boolean);
192 st.bridgeId = Math.random().toString(36).slice(2);
193 st.bridgeAt = Date.now();
194 window.postMessage({ tag: TAG, dir: 'req', id: st.bridgeId, ids }, window.location.origin);
195 }
196
197 // ---------------- layout helpers ----------------
198 function placementAnchor(card) {
199 // Climb through wrappers that hold nothing but the ticket (same width, single child): the panel goes right
200 // under the ticket's outer box, before the terms line and the related-markets list.
201 const w = card.getBoundingClientRect().width;
202 let el = card;
203 for (let i = 0; i < 10 && el.parentElement && el.parentElement !== document.body; i++) {
204 const parent = el.parentElement;
205 const kids = [...parent.children].filter((c) => c !== panel.host && c.getBoundingClientRect().height > 0);
206 if (kids.length !== 1 || parent.getBoundingClientRect().width > w + 48) break;
207 el = parent;
208 }
209 return el;
210 }
211
212 function isDarkPage() {
213 // polymarket.com: <html data-theme="dark|light"> (localStorage "color-mode"); without it the site follows the OS.
214 // Never guess from the body colour: the mobile drawer paints <body> black on a light page.
215 const theme = document.documentElement.getAttribute('data-theme');
216 if (theme === 'dark' || theme === 'light') return theme === 'dark';
217 if (document.documentElement.classList.contains('dark')) return true;
218 return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
219 }
220
221 /** The site's mobile trade drawer (Vaul) is open: anything we float over it steals its taps. */
222 function drawerOpen() {
223 for (const el of document.querySelectorAll('[data-vaul-drawer]')) if (reader.isVisible(el)) return true;
224 return false;
225 }
226
227 /** Panel language: explicit setting, else the site's own language (/ru/… sets <html lang>), else the browser. */
228 function applyLang() {
229 if (settings.lang && settings.lang !== 'auto') { if (i18n.lang !== settings.lang) i18n.setLang(settings.lang); return; }
230 const site = (document.documentElement.getAttribute('lang') || '').toLowerCase().split('-')[0];
231 const want = i18n.dicts[site] ? site : i18n.detect();
232 if (i18n.lang !== want) i18n.setLang(want);
233 }
234
235 // ---------------- actions from the panel ----------------
236 function onAction(type, payload) {
237 if (type === 'toggleCollapse') {
238 settings.collapsed = !settings.collapsed;
239 if (!settings.collapsed) st.bookAt = 0; // expanded: show a fresh book, not "18 s ago"
240 try { chrome.storage.sync.set({ collapsed: settings.collapsed }); } catch (e) { /* ignore */ }
241 } else if (type === 'floatOpen') {
242 st.floatOpen = true; st.bookAt = 0;
243 } else if (type === 'floatClose') {
244 st.floatOpen = false;
245 } else if (type === 'openJournal') {
246 send({ type: 'openJournal' }).catch(() => {});
247 } else if (type === 'openSettings') {
248 send({ type: 'openOptions' }).catch(() => {});
249 } else if (type === 'manualMarket') {
250 st.manual.marketId = payload || null;
251 } else if (type === 'manualSide') {
252 st.manual.side = payload;
253 } else if (type === 'manualOutcome') {
254 st.manual.outcomeIdx = payload;
255 } else if (type === 'manualAmount') {
256 st.manual.amountRaw = String(payload || '').slice(0, 24);
257 st.manual.amount = reader.parseNum(st.manual.amountRaw, { decimalComma: true });
258 }
259 schedule();
260 }
261
262 // ---------------- profile button ----------------
263 function profileButton(r) {
264 if (!F.journal || !r || r.kind !== 'profile') {
265 if (profileBtn) { profileBtn.remove(); profileBtn = null; }
266 return;
267 }
268 if (!profileBtn) {
269 profileBtn = document.createElement('div');
270 profileBtn.setAttribute('data-pwl', 'profile');
271 const sh = profileBtn.attachShadow({ mode: 'open' });
272 const style = document.createElement('style');
273 style.textContent = `button{all:initial;position:fixed;right:16px;bottom:16px;z-index:2147483000;cursor:pointer;
274 font:600 13px/1 system-ui,-apple-system,"Segoe UI",sans-serif;color:#fff;background:#2a78d6;padding:11px 14px;
275 border-radius:12px;box-shadow:0 6px 20px rgba(0,0,0,.18)}button:hover{background:#256abf}`;
276 const b = document.createElement('button');
277 b.addEventListener('click', () => {
278 const cur = route();
279 if (cur && cur.kind === 'profile') send({ type: 'openJournal', address: cur.address, user: cur.user }).catch(() => {});
280 });
281 sh.append(style, b);
282 document.body.append(profileBtn);
283 }
284 profileBtn.shadowRoot.querySelector('button').textContent = '📒 ' + t('profile.button');
285 }
286
287 // ---------------- main loop ----------------
288 let scheduled = false;
289 function schedule() {
290 if (scheduled) return;
291 scheduled = true;
292 setTimeout(() => { scheduled = false; tick(); }, 60);
293 }
294
295 function marketLabel(m) {
296 return m.groupItemTitle || m.question || m.slug;
297 }
298
299 function tick() {
300 try {
301 tickInner();
302 if (document.documentElement.hasAttribute('data-pwl-error')) document.documentElement.removeAttribute('data-pwl-error');
303 } catch (e) {
304 // Surface the failure for debugging without breaking the page; the next tick retries.
305 document.documentElement.setAttribute('data-pwl-error', String(e && e.message || e).slice(0, 200));
306 }
307 }
308
309 function tickInner() {
310 if (dead) { hideAll(); return; }
311 const r = route();
312 if (!pageReady(F.cost && !!(reader.anchorInput() || reader.presetButtons()[0]))) return; // no bridge in the Journal build
313 applyLang();
314 profileButton(r);
315 if (!F.cost || !settings.panel || !r || r.kind !== 'event') { hideAll(); return; }
316
317 if (r.slug !== st.slug) {
318 Object.assign(st, {
319 slug: r.slug, marketSlugHint: r.marketSlug, event: null, eventErr: null, eventLoading: false, eventRetryAt: 0,
320 ticketSig: null, conditionId: null, bridgeId: null, book: null, bookToken: null, bookAt: 0, bookErr: null, noBook: false,
321 });
322 st.manual = { marketId: null, outcomeIdx: 0, side: 'buy', amount: null, amountRaw: '' };
323 loadEvent(r.slug);
324 } else if (!st.event && !st.eventLoading && st.eventErr && Date.now() > st.eventRetryAt) {
325 loadEvent(r.slug);
326 }
327
328 const card = reader.findTicket();
329 if (card && !card.hasAttribute('data-pwl-ticket') || (card && document.querySelectorAll('[data-pwl-ticket]').length > 1)) {
330 for (const old of document.querySelectorAll('[data-pwl-ticket]')) old.removeAttribute('data-pwl-ticket');
331 card.setAttribute('data-pwl-ticket', '1');
332 }
333 const tk = card ? reader.readTicket(card) : null;
334 panel.setTheme(isDarkPage());
335 if (card) panel.mountAfter(placementAnchor(card));
336 else if (drawerOpen()) { hideAll(); return; }
337 else if (st.event || st.eventLoading) panel.mountFloat();
338
339 const vm = { collapsed: !!settings.collapsed, benchmarkApr: (Number(settings.benchmarkApr) || 0) / 100, synced: !!tk,
340 floatMin: !card && !st.floatOpen };
341 if (!st.event) {
342 if (st.eventErr === 'not found') { hideAll(); return; }
343 stream.close(); // never keep the previous market's socket alive
344 vm.status = st.eventErr ? 'error' : 'loading';
345 vm.error = st.eventErr;
346 panel.render(vm);
347 return;
348 }
349
350 // ---- which market? ----
351 if (tk && !st.bridgeId && (tk.signature !== st.ticketSig || Date.now() - st.bridgeAt > 5000)) {
352 st.ticketSig = tk.signature;
353 askBridge();
354 }
355 if (st.bridgeId && Date.now() - st.bridgeAt > 1500) st.bridgeId = null; // bridge did not answer
356 const markets = st.event.markets.filter((m) => m.tokenIds.length >= 2);
357 let market = null;
358 let autoMarket = true;
359 if (st.manual.marketId) { market = markets.find((m) => m.id === st.manual.marketId) || null; autoMarket = false; }
360 if (!market && st.conditionId) market = markets.find((m) => m.conditionId === st.conditionId) || null;
361 if (!market && st.marketSlugHint) market = markets.find((m) => m.slug === st.marketSlugHint) || null;
362 if (!market) market = reader.guessMarket(st.event, tk);
363 if (!market && markets.length === 1) market = markets[0];
364
365 const live = markets.filter((m) => !m.closed);
366 // Nothing tradable here (resolved event, bad market slug without a ticket): stay out of the way.
367 if (!tk && !market && !live.length) { hideAll(); return; }
368 if (!tk && !market) market = live[0]; // narrow layout, multi-market event: start with the first market
369 const pickList = markets.filter((m) => !m.closed || m === market).slice(0, 400)
370 .map((m) => ({ id: m.id, label: marketLabel(m) }));
371 vm.manual = null;
372 if (!tk) {
373 vm.synced = false;
374 vm.manual = { markets: pickList, marketId: market ? market.id : null, full: true, side: st.manual.side,
375 outcomes: market ? market.outcomes : [], outcomeIdx: st.manual.outcomeIdx, amountRaw: st.manual.amountRaw };
376 } else if (!market || !autoMarket) {
377 vm.manual = { markets: pickList, marketId: market ? market.id : null, full: false };
378 }
379 if (!market) { stream.close(); vm.status = 'pick'; panel.render(vm); return; }
380
381 // ---- outcome / side / amounts ----
382 let idx, side, mode, amount = null, shares = null, limitPrice = null;
383 if (tk) {
384 idx = reader.outcomeIndex(market, tk);
385 side = tk.side; mode = tk.mode; amount = tk.amount; shares = tk.shares; limitPrice = tk.limitPrice;
386 } else {
387 idx = Math.min(st.manual.outcomeIdx, market.outcomes.length - 1);
388 side = st.manual.side; mode = 'market';
389 if (side === 'buy') amount = st.manual.amount; else shares = st.manual.amount;
390 }
391 if (idx == null || idx < 0) idx = 0;
392 const tokenId = market.tokenIds[idx];
393 vm.context = {
394 side: mode === 'limit' ? t('panel.limit') + ' · ' + t('panel.' + side) : mode === 'onetap' ? '1-Tap · ' + t('panel.' + side) : t('panel.' + side),
395 outcome: market.outcomes[idx] || '',
396 market: markets.length > 1 ? marketLabel(market) : '',
397 };
398 vm.closed = market.closed || !market.acceptingOrders;
399
400 // ---- book ----
401 if (tokenId !== st.bookToken) {
402 Object.assign(st, { bookToken: tokenId, book: null, bookAt: 0, bookErr: null, noBook: false, bookTick: null, bookMin: 0 });
403 }
404 // Gamma flags archiving on the EVENT (markets keep archived=false and CLOB still serves a stale book).
405 if (market.closed || market.archived || st.event.archived || market.enableOrderBook === false) st.noBook = true;
406 if (st.noBook) {
407 stream.close();
408 vm.status = 'nobook';
409 panel.render(vm);
410 return;
411 }
412 const tabVisible = document.visibilityState === 'visible';
413 const collapsedDocked = settings.collapsed && !!card; // the floating panel ignores "collapsed"
414 const visible = tabVisible && !collapsedDocked && !vm.floatMin;
415 if (visible) stream.subscribe(tokenId); else stream.close(); // background tab / collapsed / minimized: no socket
416 const liveRaw = stream.ready && stream.token === tokenId ? stream.raw() : null;
417 if (liveRaw) {
418 st.book = calc.normalizeBook(Object.assign(liveRaw, { tick_size: stream.tick || st.bookTick || 0.01, min_order_size: st.bookMin || 0 }));
419 vm.live = true;
420 }
421 const endsSoon = market.endDate && Date.parse(market.endDate) - Date.now() < 86400000;
422 // collapsed docked panel still shows a one-line summary: keep it fresh with a slow REST poll
423 const every = collapsedDocked ? BOOK_REFRESH_COLLAPSED_MS : endsSoon || market.sportsMarketType ? BOOK_REFRESH_FAST_MS : BOOK_REFRESH_MS;
424 const polling = !liveRaw && tabVisible && !vm.floatMin;
425 // REST: the first snapshot (tick size, min order size) always; afterwards only while the socket is not live
426 if (st.bookLoading !== tokenId && (!st.bookAt || (polling && Date.now() - st.bookAt > every))) loadBook(tokenId);
427 if (!st.book) {
428 vm.status = st.bookErr ? 'error' : 'loading';
429 vm.error = st.bookErr;
430 panel.render(vm);
431 return;
432 }
433 vm.bookAge = vm.live ? null : Math.max(0, Math.round((Date.now() - st.bookAt) / 1000));
434
435 // ---- compute ----
436 const feeModel = calc.feeModel(market);
437 const holdingRate = market.holdingRewardsEnabled ? (st.event.holdingRate || DEFAULT_HOLDING_RATE) : 0;
438 // Minute resolution keeps days/APR stable between ticks (no re-render every 600 ms).
439 const now = Math.floor(Date.now() / 60000) * 60000;
440 let endDate = market.endDate || st.event.endDate;
441 // Sports game markets carry the game START as endDate: it is neither the resolution time nor "passed" while live.
442 if (market.sportsMarketType) endDate = null;
443 const ctx = { book: st.book, fee: feeModel, endDate, now, holdingRate, benchmarkApr: vm.benchmarkApr };
444 vm.feeModel = { rate: feeModel.rate, type: feeModel.type };
445 vm.holdingRate = holdingRate;
446 vm.endDateText = ctx.endDate ? fmt.dateIso(ctx.endDate) : null;
447 vm.status = 'ok';
448 if (mode === 'onetap') {
449 // the preset buttons promise amount / best ask; show what each one really pays after the fee
450 if (side === 'buy' && tk.presets.length) {
451 vm.result = { kind: 'onetap', rows: tk.presets.map((p) => ({ usd: p.usd, claim: p.win, r: calc.analyzeBuy(ctx, p.usd) })) };
452 } else { vm.quote = calc.quote(ctx, side); vm.hint = t('panel.enterShares'); }
453 } else if (mode === 'limit') {
454 if (limitPrice > 0 && shares > 0) vm.result = calc.analyzeLimit(ctx, side, limitPrice, shares);
455 else { vm.quote = calc.quote(ctx, side); vm.hint = t('panel.enterLimit'); }
456 } else if (side === 'buy') {
457 if (amount > 0) vm.result = calc.analyzeBuy(ctx, amount);
458 else { vm.quote = calc.quote(ctx, 'buy'); vm.hint = tk ? t('panel.enterAmount') : t('panel.enterManual'); }
459 } else if (shares > 0) vm.result = calc.analyzeSell(ctx, shares);
460 else { vm.quote = calc.quote(ctx, 'sell'); vm.hint = tk ? t('panel.enterShares') : t('panel.enterManual'); }
461 panel.render(vm);
462 // Machine-readable snapshot (numbers and ids only) for debugging and the e2e suite.
463 const res = vm.result || {};
464 panel.host.dataset.pwlState = JSON.stringify({
465 conditionId: market.conditionId, outcomeIdx: idx, side, mode, synced: vm.synced, feeRate: feeModel.rate, live: !!vm.live,
466 oneTap: res.kind === 'onetap' ? res.rows.map((x) => ({ usd: x.usd, claim: x.claim, payout: x.r.payout, fee: x.r.fee })) : undefined,
467 kind: res.kind || null, shares: res.shares, payout: res.payout, totalCost: res.totalCost, fee: res.fee, net: res.net,
468 gross: res.gross, avgPrice: res.avgPrice, roi: res.roi, days: res.days, holdingUsd: res.holdingUsd, warnings: res.warnings || [],
469 });
470 }
471
472 // ---------------- start ----------------
473 loadSettings().then(() => {
474 applyLang();
475 tick();
476 setInterval(tick, 600);
477 document.addEventListener('input', schedule, true);
478 document.addEventListener('click', schedule, true);
479 document.addEventListener('visibilitychange', schedule);
480 });
481})();