PolyTools Trade Cost 0.1.1 / src/content/panel.js

425 lines · 26,034 bytes · SHA-256 253c13b2db3e486f9a9b1398c54797111251f8159b909b7454af2df57c5daceb

1/*
2 * PolyTools — the trade-cost panel (Shadow DOM, no innerHTML with data).
3 * create({onAction}) → { mountAfter(el), mountFloat(), hide(), render(vm), setTheme(dark), host }
4 */
5(function (root) {
6 'use strict';
7
8 const CSS = `
9:host { all: initial; }
10.pwl { --bg:#ffffff; --surface:#fcfcfb; --ink:#0b0b0b; --ink2:#52514e; --muted:#6b6a65; --line:rgba(11,11,11,0.10);
11 --pos:#006300; --neg:#c02f2f; --accent:#2a78d6; --link:#1f66bd; --tip-bg:rgba(42,120,214,0.08); --warn-bg:rgba(250,178,25,0.14); --warn:#8a5a00;
12 font: 13px/1.45 system-ui, -apple-system, "Segoe UI", Inter, Roboto, sans-serif; color: var(--ink);
13 background: var(--bg); border: 1px solid var(--line); border-radius: 14px; box-sizing: border-box;
14 width: 100%; margin: 12px 0 0; overflow: hidden; }
15.pwl.dark { --bg:#1c1f27; --surface:#181b22; --ink:#f0efec; --ink2:#c3c2b7; --muted:#9a988f; --line:rgba(255,255,255,0.10);
16 --pos:#2fbf4f; --neg:#ef7b7b; --accent:#3987e5; --link:#6ea8f0; --tip-bg:rgba(57,135,229,0.14); --warn-bg:rgba(250,178,25,0.16); --warn:#fab219; }
17.pwl.float { position: fixed; right: 16px; bottom: 16px; width: 340px; max-width: calc(100vw - 32px); z-index: 2147483000; margin: 0;
18 box-shadow: 0 8px 28px rgba(0,0,0,0.18); max-height: 80vh; max-height: 80dvh; overflow: auto; }
19/* minimized floating mode (no ticket on screen: narrow layouts): a small round button that covers nothing important */
20/* right edge at ~42% height: on narrow market pages that is the price chart (no controls); the site's sticky
21 Yes/No bar, tab bar and section headers stay free (checked at 390, 900 and 1000 px wide on 2026-10-28) */
22.pwl.float.min { width: auto; left: auto; right: 10px; top: 42vh; bottom: auto; border-radius: 999px; overflow: visible; box-shadow: 0 4px 14px rgba(0,0,0,0.22); }
23.pill { display:flex; align-items:center; justify-content:center; width:44px; height:44px; border:0; border-radius:999px; background: var(--bg); padding:0; }
24.pill img { width:26px; height:26px; }
25.summary { padding: 0 12px 10px; color: var(--ink2); font-size:12px; font-variant-numeric: tabular-nums; }
26* { box-sizing: border-box; }
27header { display:flex; align-items:center; gap:8px; padding:10px 12px 8px; }
28.logo { width:18px; height:18px; flex:none; }
29.brand { font-weight:600; font-size:13px; }
30.title { color: var(--ink2); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1; }
31button { font: inherit; color: inherit; cursor: pointer; }
32.icon-btn { border:0; background:transparent; color: var(--muted); padding:6px 9px; min-width:30px; min-height:30px; border-radius:8px; font-size:16px; line-height:1; }
33.icon-btn:hover { background: var(--line); color: var(--ink); }
34.ctx { display:flex; align-items:center; gap:6px; padding:0 12px 8px; color: var(--ink2); font-size:12px; flex-wrap:wrap; }
35.ctx b { color: var(--ink); font-weight:600; }
36.dot { width:6px; height:6px; border-radius:50%; background: var(--pos); flex:none; }
37.dot.off { background: var(--muted); }
38.sync { color: var(--muted); font-size:11px; margin-left:auto; display:flex; align-items:center; gap:4px; }
39.hero { padding:10px 12px; border-top:1px solid var(--line); border-bottom:1px solid var(--line); background: var(--surface); }
40.hero-label { font-size:11px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
41.hero-row { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; margin-top:2px; }
42.hero-val { font-size:22px; font-weight:650; }
43.hero-sub { color: var(--ink2); font-size:13px; }
44.pos { color: var(--pos); } .neg { color: var(--neg); }
45.rows { padding:6px 12px 4px; }
46.row { display:flex; justify-content:space-between; gap:12px; padding:4px 0; }
47.row .k { color: var(--ink2); flex: 0 1 auto; max-width: 55%; }
48.row .v { text-align:right; font-variant-numeric: tabular-nums; flex: 1 1 0; min-width: 0; }
49.row .v small { display:block; color: var(--muted); font-size:11px; }
50.note { margin: 6px 12px; padding:8px 10px; border-radius:10px; font-size:12px; display:flex; gap:8px; }
51.note .ic { flex:none; }
52.tip { background: var(--tip-bg); }
53.warn { background: var(--warn-bg); }
54.msg { padding: 10px 12px; color: var(--ink2); }
55.foot { display:flex; align-items:center; gap:10px; padding:8px 12px 10px; color: var(--muted); font-size:11px; border-top:1px solid var(--line); margin-top:6px; flex-wrap:wrap; }
56.linkbtn { border:0; background:none; padding:0; color: var(--link); font: inherit; font-size:11px; cursor:pointer; }
57.linkbtn:hover { text-decoration:underline; }
58.foot .live { color: var(--pos); }
59button:focus-visible, select:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
60.manual { padding: 0 12px 8px; display:grid; gap:6px; }
61.manual label { font-size:11px; color: var(--muted); display:block; margin-bottom:2px; }
62select, input { font: inherit; color: var(--ink); background: var(--bg); border:1px solid var(--line); border-radius:8px; padding:6px 8px; width:100%; }
63.seg { display:flex; gap:4px; flex-wrap:wrap; }
64.seg button { flex:1; border:1px solid var(--line); background: var(--bg); border-radius:8px; padding:5px 6px; font-size:12px; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
65.seg button.on { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); font-weight:600; }
66.collapsed .body { display:none; }
67.disc { color: var(--muted); font-size:10.5px; padding: 0 12px 10px; }
68`;
69
70 // PolyTools mark (same artwork as polytools.vip and the toolbar icon)
71 const LOGO = 'data:image/svg+xml;utf8,' + encodeURIComponent(
72 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#0d1119"/>' +
73 '<g transform="translate(3.2 1.3) scale(.8)"><path fill="#e8b65a" d="M12.9 25.4C13.6 22 14.1 18.6 14.3 15.5 11.4 15.6 7.5 14.8 4.9 12.6 ' +
74 '3.5 11.4 2.7 9.4 2.5 7.2 5.3 9 8.9 9.6 11.9 9.6c1.8 0 3.3.6 4.1 1.8.8-1.2 2.3-1.8 4.1-1.8 3 0 6.6-.6 9.4-2.4-.2 2.2-1 4.2-2.4 5.4' +
75 '-2.6 2.2-6.5 3-9.4 2.9.2 3.1.7 6.5 1.4 9.9z"/><path fill="none" stroke="#e8b65a" stroke-width="1.6" stroke-linecap="round" ' +
76 'd="M3.5 28.4c1.7 0 2.5-1.1 4.2-1.1s2.5 1.1 4.2 1.1 2.5-1.1 4.1-1.1 2.5 1.1 4.2 1.1 2.5-1.1 4.2-1.1 2.5 1.1 4.1 1.1"/></g></svg>');
77
78 function h(tag, attrs, ...kids) {
79 const el = document.createElement(tag);
80 if (attrs) for (const [k, v] of Object.entries(attrs)) {
81 if (v == null || v === false) continue;
82 if (k === 'class') el.className = v;
83 else if (k === 'text') el.textContent = v;
84 else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
85 else el.setAttribute(k, v === true ? '' : v);
86 }
87 for (const kid of kids.flat()) if (kid != null && kid !== false) el.append(kid instanceof Node ? kid : document.createTextNode(String(kid)));
88 return el;
89 }
90
91 function create(opts) {
92 const { i18n, fmt } = root.PWL;
93 const journal = !root.PWL.flavor || root.PWL.flavor.journal; // the Trade Cost build has no journal page
94 const t = i18n.t;
95 const host = document.createElement('div');
96 host.id = 'pwl-lens-host';
97 host.setAttribute('data-pwl', '1');
98 const shadow = host.attachShadow({ mode: 'open' });
99 shadow.append(h('style', { text: CSS }));
100 const box = h('div', { class: 'pwl' });
101 shadow.append(box);
102 let dark = false, floating = false, collapsed = false;
103 const act = (type, payload) => opts.onAction && opts.onAction(type, payload);
104
105 function mountAfter(anchor) {
106 floating = false;
107 box.classList.remove('float');
108 if (anchor && anchor.parentElement && anchor.nextSibling !== host) anchor.after(host);
109 host.style.display = '';
110 }
111 function mountFloat() {
112 floating = true;
113 box.classList.add('float');
114 if (host.parentElement !== document.body) document.body.append(host);
115 host.style.display = '';
116 }
117 function hide() { host.style.display = 'none'; }
118 function setTheme(isDark) { if (isDark !== dark) { dark = isDark; box.classList.toggle('dark', dark); } }
119 function setCollapsed(c) { collapsed = !!c; box.classList.toggle('collapsed', collapsed); }
120
121 // Annualizing a long-shot payoff (ROI > 100%) produces meaningless numbers — only show APR for "bond-like" trades.
122 // ... and only when the horizon is at least a week (shorter ones turn +3% into +5,000%/yr).
123 const aprShown = (r) => r && r.apr && r.apr.simple != null && r.apr.reliable && !r.expired && r.roi != null && r.roi <= 1;
124 const signClass = (x) => (x > 0.0000001 ? 'pos' : x < -0.0000001 ? 'neg' : '');
125 const row = (k, v, sub, cls) => h('div', { class: 'row' }, h('span', { class: 'k', text: k }),
126 h('span', { class: 'v' + (cls ? ' ' + cls : '') }, v, sub ? h('small', { text: sub }) : null));
127 const note = (kind, icon, text) => h('div', { class: 'note ' + kind, role: kind === 'warn' ? 'status' : null },
128 h('span', { class: 'ic', 'aria-hidden': 'true', text: icon }), h('span', { text }));
129
130 function feeRow(r, feeModel) {
131 if (r.fee > 0) {
132 return row(t('panel.fee'), fmt.money(r.fee), [fmt.pct(r.feePct),
133 r.feeShareOfProfit != null ? t('panel.feeOfProfit', { pct: fmt.pct(r.feeShareOfProfit) }) : null].filter(Boolean).join(' · '));
134 }
135 return row(t('panel.fee'), '$0', feeModel.type === 'zero_fees' ? t('panel.feePromo') : !feeModel.rate ? t('panel.feeNone') : null);
136 }
137
138 function horizonRows(r, vm) {
139 const out = [];
140 if (r.days != null) {
141 out.push(row(t('panel.until'), r.expired ? t('panel.endPassed') : fmt.duration(r.days, t), vm.endDateText));
142 }
143 if (aprShown(r)) {
144 out.push(row(t('panel.apr'), fmt.pct(r.apr.simple, null, true), null, signClass(r.apr.simple)));
145 } else if (r.apr && r.apr.simple != null && !r.apr.reliable && !r.expired && r.roi != null && r.roi <= 1) {
146 out.push(row(t('panel.apr'), fmt.NA, t('panel.aprShort')));
147 }
148 if (r.holdingUsd > 0) {
149 out.push(row(t('panel.holding', { rate: fmt.pct(r.holdingRate || vm.holdingRate) }), '+' + fmt.money(r.holdingUsd),
150 r.aprWithHolding && r.aprWithHolding.simple != null && aprShown(r)
151 ? t('panel.withHolding', { roi: fmt.pct(r.roiWithHolding, null, true), apr: fmt.pct(r.aprWithHolding.simple, null, true) })
152 : t('panel.holdingVal', { usd: fmt.money(r.holdingUsd) })));
153 }
154 return out;
155 }
156
157 function warnings(list, r) {
158 return list.map((w) => {
159 const vars = {
160 usd: fmt.money(r.unfilledUsd), n: fmt.number(w === 'belowMinSize' ? r.minSize : r.unfilledShares, 2),
161 pct: fmt.pct(w === 'feeEatsProfit' ? r.feeShareOfProfit : r.slippage), b: fmt.pct(r.benchmarkApr),
162 };
163 const key = w === 'unfilled' && r.kind === 'sell' ? 'warn.unfilledShares' : 'warn.' + w;
164 return note('warn', '⚠', t(key, vars));
165 });
166 }
167
168 /** Nothing to fill (empty side of the book, nothing at this size): show the warning only. */
169 function emptyBody(r, vm) {
170 return warnings(r.warnings.length ? r.warnings : ['noFill'], Object.assign({ benchmarkApr: vm.benchmarkApr }, r));
171 }
172
173 function buyBody(r, vm) {
174 if (r.noLiquidity || !(r.shares > 0)) return emptyBody(r, vm);
175 const parts = [];
176 parts.push(h('div', { class: 'hero' },
177 h('div', { class: 'hero-label', text: t('panel.ifWins') }),
178 h('div', { class: 'hero-row' },
179 h('span', { class: 'hero-val ' + signClass(r.profit), text: fmt.money(r.profit, { sign: true }) }),
180 h('span', { class: 'hero-sub', text: fmt.pct(r.roi, null, true) + (aprShown(r) ? ' · ' + fmt.pct(r.apr.simple, null, true) + t('panel.perYear') : '') }))));
181 const rows = h('div', { class: 'rows' });
182 rows.append(
183 row(t('panel.avgPrice'), fmt.cents(r.avgPrice), [t('panel.bestPrice', { price: fmt.cents(r.bestAsk) }),
184 r.fee > 0 ? t('panel.withFee', { price: fmt.cents(r.totalCost / r.shares, 2) }) : null].filter(Boolean).join(' · ')),
185 row(t('panel.slippage'), fmt.pct(r.slippage), fmt.money(r.slippageUsd) + ' · ' + t('panel.levelsN', { n: r.levels })),
186 feeRow(r, vm.feeModel),
187 row(t('panel.totalCost'), fmt.money(r.totalCost), r.allIn
188 ? t('panel.ofAmount', { usd: fmt.money(r.usd), n: fmt.number(r.shares, 2) })
189 : fmt.number(r.shares, 2) + ' × $1'),
190 row(t('panel.payout'), fmt.money(r.payout)),
191 row(t('panel.breakeven'), fmt.pct(r.breakeven, 2),
192 r.mid != null ? t('panel.breakevenHint', { mid: fmt.pct(r.mid, 2) }) + (r.edgeNeeded > 0.0005 ? ' · ' + t('panel.edgeNeeded', { pp: fmt.pp(r.edgeNeeded, i18n.lang) }) : '') : null),
193 ...horizonRows(r, vm));
194 parts.push(rows);
195 if (r.maker && r.maker.saving > 0.005) {
196 parts.push(note('tip', '💡', t('panel.makerBuy', { price: fmt.cents(r.maker.price), cost: fmt.money(r.maker.cost), saving: fmt.money(r.maker.saving) })));
197 }
198 parts.push(...warnings(r.warnings, Object.assign({ benchmarkApr: vm.benchmarkApr }, r)));
199 return parts;
200 }
201
202 function sellBody(r, vm) {
203 if (r.noLiquidity || !(r.shares > 0)) return emptyBody(r, vm);
204 const parts = [];
205 parts.push(h('div', { class: 'hero' },
206 h('div', { class: 'hero-label', text: t('panel.proceeds') }),
207 h('div', { class: 'hero-row' },
208 h('span', { class: 'hero-val', text: fmt.money(r.net) }),
209 h('span', { class: 'hero-sub', text: fmt.cents(r.netPerShare) + ' ' + t('panel.perShare') }))));
210 const rows = h('div', { class: 'rows' });
211 rows.append(
212 row(t('panel.avgPrice'), fmt.cents(r.avgPrice), t('panel.bestPrice', { price: fmt.cents(r.bestBid) })),
213 row(t('panel.slippage'), fmt.pct(r.slippage), fmt.money(r.slippageUsd) + ' · ' + t('panel.levelsN', { n: r.levels })),
214 row(t('panel.gross'), fmt.money(r.gross)),
215 feeRow(r, vm.feeModel),
216 ...horizonRows(Object.assign({}, r, { apr: null }), vm));
217 parts.push(rows);
218 parts.push(note('tip', '⚖', t('panel.holdCompare', { payout: fmt.money(r.holdPayout), p: fmt.pct(r.netPerShare, 1) })));
219 if (r.maker && r.maker.gain > 0.005) {
220 parts.push(note('tip', '💡', t('panel.makerSell', { price: fmt.cents(r.maker.price), gain: fmt.money(r.maker.gain) })));
221 }
222 parts.push(...warnings(r.warnings, Object.assign({ benchmarkApr: vm.benchmarkApr }, r)));
223 return parts;
224 }
225
226 function limitBody(r, vm) {
227 const parts = [];
228 if (r.side === 'buy') {
229 parts.push(h('div', { class: 'hero' },
230 h('div', { class: 'hero-label', text: t('panel.ifWins') }),
231 h('div', { class: 'hero-row' },
232 h('span', { class: 'hero-val ' + signClass(r.profit), text: fmt.money(r.profit, { sign: true }) }),
233 h('span', { class: 'hero-sub', text: fmt.pct(r.roi, null, true) + (aprShown(r) ? ' · ' + fmt.pct(r.apr.simple, null, true) + t('panel.perYear') : '') }))));
234 } else {
235 parts.push(h('div', { class: 'hero' },
236 h('div', { class: 'hero-label', text: t('panel.limitNet') }),
237 h('div', { class: 'hero-row' }, h('span', { class: 'hero-val', text: fmt.money(r.net) }),
238 h('span', { class: 'hero-sub', text: fmt.cents(r.netPerShare) + ' ' + t('panel.perShare') }))));
239 }
240 const rows = h('div', { class: 'rows' });
241 if (r.side === 'buy') {
242 rows.append(
243 row(t('panel.limitCost'), fmt.money(r.totalCost), fmt.number(r.qty, 2) + ' × ' + fmt.cents(r.price)),
244 row(t('panel.fee'), fmt.money(r.fee)),
245 row(t('panel.payout'), fmt.money(r.payout)),
246 row(t('panel.breakeven'), fmt.pct(r.breakeven, 2), r.mid != null ? t('panel.breakevenHint', { mid: fmt.pct(r.mid, 2) }) : null),
247 ...horizonRows(r, vm));
248 } else {
249 rows.append(row(t('panel.fee'), fmt.money(r.fee)), ...horizonRows(Object.assign({}, r, { apr: null }), vm));
250 }
251 parts.push(rows);
252 parts.push(note('tip', r.marketable ? '⚡' : '🧾', r.marketable
253 ? t('panel.limitTaker', { n: fmt.number(r.taker.shares, 2), fee: fmt.money(r.fee), m: fmt.number(r.restingShares, 2) })
254 : t('panel.limitPassive')));
255 if (r.side === 'sell') parts.push(note('tip', '⚖', t('panel.holdCompare', { payout: fmt.money(r.holdPayout), p: fmt.pct(r.netPerShare, 1) })));
256 parts.push(...warnings(r.warnings, Object.assign({ benchmarkApr: vm.benchmarkApr }, r)));
257 return parts;
258 }
259
260 function oneTapBody(res, vm) {
261 const rows = h('div', { class: 'rows' });
262 let last = null;
263 for (const x of res.rows) {
264 const r = x.r;
265 const label = fmt.money(x.usd, { digits: 0 });
266 if (r.noLiquidity || !(r.shares > 0)) { rows.append(row(label, fmt.NA)); continue; }
267 rows.append(row(label, fmt.money(r.payout), t('panel.oneTapSub', { claim: fmt.money(x.claim), fee: fmt.money(r.fee) })));
268 last = r;
269 }
270 const parts = [h('div', { class: 'hero' }, h('div', { class: 'hero-label', text: t('panel.oneTap') })), rows];
271 if (last) parts.push(...warnings(last.warnings, Object.assign({ benchmarkApr: vm.benchmarkApr }, last)));
272 return parts;
273 }
274
275 function quoteBody(q, vm) {
276 if (!q) return [h('div', { class: 'msg', text: vm.hint })];
277 const lines = [];
278 if (q.side === 'buy') {
279 let s = t('panel.quoteBuy', { price: fmt.cents(q.price), be: fmt.pct(q.breakeven, 2), roi: fmt.pct(q.roi, null, true) });
280 if (aprShown(q)) s += ' · ' + t('panel.quoteApr', { apr: fmt.pct(q.apr.simple, null, true) });
281 lines.push(h('div', { class: 'msg', text: s }));
282 if (q.feePerShare > 0) lines.push(h('div', { class: 'msg', text: t('panel.quoteFee', { fee: fmt.money(q.feePer100) }) }));
283 } else {
284 lines.push(h('div', { class: 'msg', text: t('panel.quoteSell', { price: fmt.cents(q.price), net: fmt.cents(q.netPerShare) }) }));
285 }
286 lines.push(h('div', { class: 'msg', text: vm.hint }));
287 return lines;
288 }
289
290 function manualBlock(vm) {
291 const m = vm.manual;
292 if (!m) return null;
293 const wrap = h('div', { class: 'manual' });
294 if (m.markets && m.markets.length > 1) {
295 const sel = h('select', { 'data-fk': 'market', 'aria-label': t('panel.market'), onchange: (e) => act('manualMarket', e.target.value) },
296 h('option', { value: '', text: t('panel.pickMarket') }),
297 m.markets.map((mk) => h('option', { value: mk.id, selected: mk.id === m.marketId, text: mk.label })));
298 wrap.append(h('div', null, h('label', { text: t('panel.market') }), sel));
299 }
300 if (m.full) {
301 wrap.append(h('div', { class: 'seg' },
302 ['buy', 'sell'].map((s) => h('button', { 'data-fk': 'side-' + s, class: m.side === s ? 'on' : '', 'aria-pressed': String(m.side === s), onclick: () => act('manualSide', s), text: t('panel.' + s) }))));
303 if (m.outcomes && m.outcomes.length) {
304 wrap.append(h('div', { class: 'seg' },
305 m.outcomes.map((o, i) => h('button', { 'data-fk': 'out-' + i, class: m.outcomeIdx === i ? 'on' : '', 'aria-pressed': String(m.outcomeIdx === i), title: o, onclick: () => act('manualOutcome', i), text: o }))));
306 }
307 const input = h('input', {
308 'data-fk': 'amount', type: 'text', inputmode: 'decimal', placeholder: m.side === 'sell' ? t('panel.shares') : t('panel.amountUsd'),
309 value: m.amountRaw || '', 'aria-label': m.side === 'sell' ? t('panel.shares') : t('panel.amountUsd'),
310 oninput: (e) => act('manualAmount', e.target.value),
311 });
312 wrap.append(input);
313 }
314 return wrap;
315 }
316
317 // The panel is rebuilt section by section: the live book refreshes every few seconds and must not recreate the
318 // manual inputs (open <select>, caret, half-typed "1.") or the header buttons.
319 const parts = { head: h('div'), body: h('div', { class: 'body' }) };
320 const sec = { ctx: h('div'), manual: h('div'), content: h('div'), foot: h('div') };
321 parts.body.append(sec.ctx, sec.manual, sec.content, sec.foot);
322 box.append(parts.head, parts.body);
323 const keys = {};
324 let ageEl = null;
325 const changed = (name, value) => {
326 const k = JSON.stringify(value, (kk, v) => (typeof v === 'number' ? Math.round(v * 1e6) / 1e6 : v));
327 if (keys[name] === k) return false;
328 keys[name] = k;
329 return true;
330 };
331 let liveNow = false;
332 function setAge(sec) {
333 if (!ageEl) return;
334 ageEl.textContent = liveNow ? '● ' + t('panel.live') : sec != null ? t('panel.updated', { s: sec }) : '';
335 ageEl.classList.toggle('live', liveNow);
336 }
337
338 function summaryText(vm) {
339 const r = vm.result;
340 if (r && r.kind === 'onetap') return '';
341 if (!r || r.noLiquidity || !(r.shares > 0 || (r.kind === 'limit' && r.qty > 0))) return '';
342 if (r.kind === 'sell' || (r.kind === 'limit' && r.side === 'sell')) return t('panel.summarySell', { net: fmt.money(r.net), fee: fmt.money(r.fee) });
343 return t('panel.summaryBuy', { profit: fmt.money(r.profit, { sign: true }), be: fmt.pct(r.breakeven, 2), fee: fmt.money(r.fee) });
344 }
345
346 function render(vm) {
347 const lang = i18n.lang;
348 const focusKey = shadow.activeElement && shadow.activeElement.getAttribute('data-fk');
349 // floating + minimized: only a small round button, nothing else
350 const pill = floating && !!vm.floatMin;
351 box.classList.toggle('min', pill);
352 if (pill) {
353 if (changed('head', ['pill', lang])) {
354 parts.head.replaceChildren(h('button', { class: 'pill', 'data-fk': 'pill', title: t('panel.openPanel'), 'aria-label': t('panel.openPanel'),
355 onclick: () => act('floatOpen') }, h('img', { src: LOGO, alt: '' })));
356 }
357 parts.body.hidden = true;
358 return;
359 }
360 parts.body.hidden = false;
361 setCollapsed(vm.collapsed && !floating);
362 const summary = vm.collapsed && !floating ? summaryText(vm) : '';
363 if (changed('head', [lang, vm.collapsed, floating, summary])) {
364 const toggleBtn = floating
365 ? h('button', { class: 'icon-btn', 'data-fk': 'toggle', title: t('panel.minimize'), 'aria-label': t('panel.minimize'), onclick: () => act('floatClose'), text: '×' })
366 : h('button', {
367 class: 'icon-btn', 'data-fk': 'toggle', title: vm.collapsed ? t('panel.expand') : t('panel.collapse'),
368 'aria-label': vm.collapsed ? t('panel.expand') : t('panel.collapse'), 'aria-expanded': String(!vm.collapsed),
369 onclick: () => act('toggleCollapse'), text: vm.collapsed ? '▸' : '▾',
370 });
371 parts.head.replaceChildren(h('header', null,
372 h('img', { class: 'logo', src: LOGO, alt: '' }),
373 h('span', { class: 'brand', text: t('brand') }),
374 h('span', { class: 'title', text: '· ' + t('panel.title') }),
375 toggleBtn), summary ? h('div', { class: 'summary', role: 'status', text: summary }) : '');
376 }
377 if (changed('ctx', [lang, vm.context || null, vm.synced])) {
378 sec.ctx.replaceChildren(vm.context ? h('div', { class: 'ctx' },
379 h('b', { text: vm.context.side }), vm.context.outcome ? '\u00a0· ' : null, vm.context.outcome ? h('b', { text: vm.context.outcome }) : null,
380 vm.context.market ? '\u00a0· ' + vm.context.market : null,
381 h('span', { class: 'sync' }, h('span', { class: 'dot' + (vm.synced ? '' : ' off') }), vm.synced ? t('panel.synced') : t('panel.manual'))) : '');
382 }
383 // the typed text lives in the input itself; it is not part of the key
384 const manualKey = vm.manual ? Object.assign({}, vm.manual, { amountRaw: undefined }) : null;
385 if (changed('manual', [lang, manualKey])) sec.manual.replaceChildren(manualBlock(vm) || '');
386 const contentKey = [lang, vm.status, vm.error, vm.result || null, vm.quote || null, vm.hint, vm.closed, vm.feeModel,
387 vm.holdingRate, vm.endDateText, vm.benchmarkApr];
388 if (changed('content', contentKey)) {
389 const out = [];
390 if (vm.status === 'loading') out.push(h('div', { class: 'msg', text: t('panel.loading') }));
391 else if (vm.status === 'error') out.push(h('div', { class: 'msg', text: t('panel.loadFail', { err: vm.error || '?' }) }));
392 else if (vm.status === 'nobook') out.push(h('div', { class: 'msg', text: t('panel.noBook') }));
393 else if (vm.status === 'pick') {
394 if (!(vm.manual && vm.manual.markets && vm.manual.markets.length > 1)) out.push(h('div', { class: 'msg', text: t('panel.pickMarket') }));
395 }
396 else if (vm.result) {
397 const r = vm.result;
398 out.push(...(r.kind === 'buy' ? buyBody(r, vm) : r.kind === 'sell' ? sellBody(r, vm) : r.kind === 'onetap' ? oneTapBody(r, vm) : limitBody(r, vm)));
399 } else out.push(...quoteBody(vm.quote, vm));
400 if (vm.closed) out.push(note('warn', '⚠', t('warn.closed')));
401 sec.content.replaceChildren(...out);
402 }
403 if (changed('foot', [lang])) {
404 ageEl = h('span');
405 sec.foot.replaceChildren(
406 h('div', { class: 'foot', role: 'group' },
407 ageEl,
408 journal ? h('button', { type: 'button', class: 'linkbtn', onclick: () => act('openJournal'), text: t('panel.openJournal') }) : null,
409 h('button', { type: 'button', class: 'linkbtn', onclick: () => act('openSettings'), text: t('panel.settings') })),
410 h('div', { class: 'disc', text: t('common.notAffiliated') }));
411 }
412 liveNow = !!vm.live;
413 setAge(vm.bookAge);
414 if (focusKey && !(shadow.activeElement && shadow.activeElement.getAttribute('data-fk') === focusKey)) {
415 const el = shadow.querySelector('[data-fk="' + focusKey + '"]'); // keep keyboard position across rebuilds
416 if (el) el.focus();
417 }
418 }
419
420 return { host, mountAfter, mountFloat, hide, render, setTheme, get floating() { return floating; } };
421 }
422
423 root.PWL = root.PWL || {};
424 root.PWL.panel = { create, h };
425})(typeof globalThis !== 'undefined' ? globalThis : this);