Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/spend.js

22.1 KiB, 1 run

created by r2519314175:1443, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* spend.js — the Spending view: where the money goes.
2 *
3 * Daimond spends from two separate pots, and this shows both without pretending
4 * they are one:
5 *
6 * Inference — the model calls themselves, billed to the user's OWN provider
7 * key. The browser talks to the provider directly, so the gateway never
8 * sees this; the cost is priced and logged per turn in `DaimondLedger`
9 * (localStorage), and read back here. This is almost always the largest
10 * line, so it comes first.
11 *
12 * Credits — a prepaid balance held on the gateway, spent only on the few
13 * things that must leave the browser: fetching a web page, syncing or
14 * sending mail, and (soon) cross-device sync. Read from `/api/ledger`,
15 * already tagged with a category so the breakdown never parses a string.
16 *
17 * The two are different currencies and different accounts, so they are shown as
18 * two tracks, never summed. Everything is drawn in plain SVG and DOM — no chart
19 * library, no dependency.
20 *
21 * Exposes `window.DaimondSpend = { onOpen, refresh, show }`. `daimond.js` calls
22 * `onOpen()` when the Spending panel is revealed; the header spend meter's click
23 * calls `DaimondPanels.show('spend')`, which routes through it.
24 */
25(function () {
26 'use strict';
27
28 var period = 'month'; // inference window: 'week' | 'month'
29 var creditEntries = []; // last read of /api/ledger
30 var wiredActions = false;
31
32 // ── Small DOM + format helpers ─────────────────────────────
33
34 function el(tag, cls, text) {
35 var e = document.createElement(tag);
36 if (cls) e.className = cls;
37 if (text != null) e.textContent = text;
38 return e;
39 }
40
41 // Inference costs are provider USD, often sub-cent, so show enough figures
42 // to be honest about a small number without a wall of zeros on a large one.
43 // The `fine` cascade in i18n.js is this rule; the display currency rides
44 // along with it.
45 function fmtUsd(v) {
46 return DaimondI18n.money(v, 'fine');
47 }
48
49 // Credits are gateway minor units; reuse the gateway's own formatter.
50 function fmtCredits(minor) {
51 var g = window.DaimondGateway;
52 var cur = (g && g.state && g.state().currency) || 'usd';
53 return DaimondI18n.moneyMinor(minor, cur);
54 }
55
56 function fmtTokens(n) {
57 n = n || 0;
58 if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
59 if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
60 return String(n);
61 }
62
63 // A short, local, human day/time for a ledger row.
64 function fmtWhen(tsMs) {
65 try {
66 var d = new Date(tsMs);
67 return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
68 + ' ' + d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
69 } catch (e) { return ''; }
70 }
71
72 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
73
74 // Every category a movement can carry. The gateway is the authority on this
75 // list: the first seven are `SPEND_CATEGORIES` in gateway/src/schema.rs, the
76 // rest are the credit-side kinds `LedgerEntry::category` names from the kind
77 // alone.
78 //
79 // This is a copy, and a copy is what went wrong: `infer` became a category on
80 // 2026-07-17 and `storage` on 2026-07-21, this list was told about neither,
81 // and for three weeks a user's own breakdown read "infer" and "storage" at
82 // them in eight languages. dev/verify_spendcats.mjs reads the Rust and fails
83 // when this list cannot name something the gateway can produce, so the next
84 // category is a red check rather than three more weeks.
85 var CATS = [
86 'web', 'search', 'mail', 'sync', 'storage', 'infer', 'other', // metered spends
87 'topup', 'refund', 'grant', 'adjust', // credit-side movements
88 ];
89
90 // Categories already reported, so the console carries one line per unknown
91 // name rather than one per row per redraw.
92 var unnamed = {};
93
94 // A friendly label for a credit category / kind.
95 //
96 // A category this build cannot name is money that left the balance with
97 // nothing to say for it, and the row says exactly that. Printing the
98 // gateway's own token instead is what let the last drift ship: "infer" looks
99 // like a label to nobody and like a bug to no one either, so nothing was
100 // reported and nothing was fixed. The token goes to the console, once, which
101 // is where a diagnostic belongs -- not beside somebody's money.
102 function catLabel(c) {
103 if (CATS.indexOf(c) !== -1) return t('spend.cat_' + c);
104 if (!c) return t('spend.cat_fallback'); // an entry that named nothing
105 if (!unnamed[c]) {
106 unnamed[c] = 1;
107 console.warn('spend: the gateway named a category this build cannot label: "' + c + '"');
108 }
109 return t('spend.cat_unlisted');
110 }
111
112 // ── The SVG bar chart ──────────────────────────────────────
113 // Bars are laid out in a 0..100 × 0..100 viewBox and stretched to the
114 // container, so the CSS owns the size and the SVG owns only the shape.
115 // Colour comes from `currentColor` and a muted track, so it themes for free.
116
117 function barChart(bars, opts) {
118 opts = opts || {};
119 var W = 100, H = 100, n = bars.length;
120 var wrap = el('div', 'spend-chart');
121 if (!n) { wrap.appendChild(el('div', 'spend-empty', opts.empty || t('spend.nothing_yet'))); return wrap; }
122
123 var max = 0;
124 for (var i = 0; i < n; i++) max = Math.max(max, bars[i].value || 0);
125 if (max <= 0) max = 1;
126
127 var gap = n > 40 ? 0.15 : (n > 14 ? 0.35 : 0.9);
128 var slot = W / n;
129 var bw = Math.max(0.5, slot - gap);
130
131 var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
132 svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
133 svg.setAttribute('preserveAspectRatio', 'none');
134 svg.setAttribute('class', 'spend-bars');
135
136 for (var k = 0; k < n; k++) {
137 var v = bars[k].value || 0;
138 var h = (v / max) * (H - 2);
139 var x = k * slot + (slot - bw) / 2;
140 // A faint full-height track so empty days read as "zero", not "missing".
141 var track = document.createElementNS(svg.namespaceURI, 'rect');
142 track.setAttribute('x', x.toFixed(2));
143 track.setAttribute('y', '0');
144 track.setAttribute('width', bw.toFixed(2));
145 track.setAttribute('height', String(H));
146 track.setAttribute('class', 'spend-bar-track');
147 svg.appendChild(track);
148 if (h > 0) {
149 var r = document.createElementNS(svg.namespaceURI, 'rect');
150 r.setAttribute('x', x.toFixed(2));
151 r.setAttribute('y', (H - h).toFixed(2));
152 r.setAttribute('width', bw.toFixed(2));
153 r.setAttribute('height', h.toFixed(2));
154 r.setAttribute('class', 'spend-bar');
155 var tip = document.createElementNS(svg.namespaceURI, 'title');
156 tip.textContent = (bars[k].label ? bars[k].label + ': ' : '') + (opts.fmt ? opts.fmt(v) : v);
157 r.appendChild(tip);
158 svg.appendChild(r);
159 }
160 }
161 wrap.appendChild(svg);
162
163 // A sparse axis: first and last labels only, so a 30-bar chart is not a
164 // smear of dates.
165 if (opts.axis !== false && n > 1) {
166 var axis = el('div', 'spend-axis');
167 axis.appendChild(el('span', null, bars[0].label || ''));
168 axis.appendChild(el('span', null, bars[n - 1].label || ''));
169 wrap.appendChild(axis);
170 }
171 return wrap;
172 }
173
174 // A horizontal category breakdown: label, proportional fill, and amount.
175 function breakdown(rows, fmt) {
176 var wrap = el('div', 'spend-breakdown');
177 var total = 0, i;
178 for (i = 0; i < rows.length; i++) total += Math.abs(rows[i].value || 0);
179 if (total <= 0) { wrap.appendChild(el('div', 'spend-empty', t('spend.nothing_spent'))); return wrap; }
180 for (i = 0; i < rows.length; i++) {
181 var v = Math.abs(rows[i].value || 0);
182 if (v <= 0) continue;
183 var row = el('div', 'spend-bd-row');
184 // The label column is one line and ellipses anything longer, and the
185 // dock is narrow: "Inference on credits" arrives as "Inference on…".
186 // The full text rides along as a tooltip so the row can still be read.
187 var lbl = el('span', 'spend-bd-label', rows[i].label);
188 lbl.title = rows[i].label;
189 row.appendChild(lbl);
190 var barWrap = el('span', 'spend-bd-bar');
191 var fill = el('span', 'spend-bd-fill');
192 fill.style.width = Math.max(2, (v / total) * 100).toFixed(1) + '%';
193 barWrap.appendChild(fill);
194 row.appendChild(barWrap);
195 row.appendChild(el('span', 'spend-bd-amt', fmt(v)));
196 wrap.appendChild(row);
197 }
198 return wrap;
199 }
200
201 function sectionHead(title, hint) {
202 var h = el('div', 'spend-sec-head');
203 h.appendChild(el('h3', 'spend-sec-title', title));
204 if (hint) h.appendChild(el('span', 'spend-sec-hint', hint));
205 return h;
206 }
207
208 // Whether a rolled-up total is entirely what the providers reported. Old
209 // entries carry no `r` and so are priced, not reported -- which is what they
210 // were, and the honest answer for them is still "≈".
211 function allReported(tot) {
212 if (!tot || !(tot.usd > 0)) return false;
213 var rep = tot.reportedUsd || 0;
214 return rep >= tot.usd - 1e-12;
215 }
216
217 // The "≈" that precedes a figure, or nothing when the figure is a bill. A
218 // converted figure already carries its own ≈ from i18n, so this adds none.
219 function mark(tot) {
220 if (allReported(tot)) return '';
221 if (window.DaimondI18n && DaimondI18n.converted()) return '';
222 return '≈ ';
223 }
224
225 // One line under the headline saying what the figure IS.
226 function provenance(tot) {
227 if (!tot || !(tot.usd > 0)) return '';
228 if (allReported(tot)) return t('spend.all_reported');
229 if ((tot.reportedUsd || 0) > 0) {
230 return t('spend.part_reported', { amount: fmtUsd(tot.reportedUsd) });
231 }
232 return tot.estimated ? t('spend.none_reported_unknown') : t('spend.none_reported');
233 }
234
235 // A second line, when the figures above have MOVED since the user last read
236 // them. Estimates made under the old rate table -- the one that ran about six
237 // times high -- were re-priced once on this device, which cut every total
238 // containing one. Dropping a number by that much without a word is how a
239 // meter loses its reader, so the period says what it used to read.
240 //
241 // `tot` is the period's total as it stands. What it read before is that
242 // total with the touched turns put back at their original guess. Nothing is
243 // said when the period holds no repriced turn, nor when the two figures
244 // round to the same thing: an explanation of a change nobody can see is
245 // noise, and this way the line ages out as the 90-day log retires the old
246 // entries.
247 function repriceNote(tot) {
248 var L = window.DaimondLedger;
249 if (!L || typeof L.repriced !== 'function' || !tot) return '';
250 var rp;
251 try { rp = L.repriced(period); } catch (e) { return ''; }
252 if (!rp || !rp.turns) return '';
253 var before = (tot.usd || 0) - (rp.usd || 0) + (rp.was || 0);
254 var was = fmtUsd(before);
255 if (was === fmtUsd(tot.usd || 0)) return '';
256 // The old figure was a guess, and says so -- unless the currency
257 // conversion has already hung a ≈ on it.
258 var approx = (window.DaimondI18n && DaimondI18n.converted()) ? '' : '≈ ';
259 return t('spend.repriced', { amount: approx + was });
260 }
261
262 // ── Inference section (from DaimondLedger) ─────────────────
263
264 function inferenceSection() {
265 var sec = el('section', 'spend-sec');
266 sec.appendChild(sectionHead(t('spend.inference'), t('spend.inference_hint')));
267
268 var L = window.DaimondLedger;
269 if (!L) { sec.appendChild(el('div', 'spend-empty', t('spend.no_usage'))); return sec; }
270
271 var totals = {};
272 try { totals = L.totals() || {}; } catch (e) { totals = {}; }
273 var win = totals[period] || { usd: 0, tokens: 0 };
274
275 // The headline: this period's spend, plus session for immediacy.
276 var head = el('div', 'spend-totals');
277 var periodLbl = period === 'week' ? t('spend.this_week') : t('spend.this_month');
278 // A converted figure already carries its own ≈, so the estimate mark is
279 // not added a second time.
280 var approx = (window.DaimondI18n && DaimondI18n.converted()) ? '' : '≈ ';
281 head.appendChild(bigStat(mark(win) + fmtUsd(win.usd), periodLbl));
282 if (totals.session) {
283 head.appendChild(bigStat(mark(totals.session) + fmtUsd(totals.session.usd), t('spend.session')));
284 }
285 head.appendChild(bigStat(fmtTokens(win.tokens) + ' ' + t('spend.tok'), periodLbl));
286 sec.appendChild(head);
287
288 // Say where the figure came from, once, under the headline. A total the
289 // providers themselves billed is a fact, and dressing it in a "≈" was
290 // telling the user it was guesswork when it was the opposite.
291 var prov = provenance(win);
292 if (prov) sec.appendChild(el('div', 'spend-note', prov));
293
294 // And, while the correction is still in view, why the figure fell.
295 var rn = repriceNote(win);
296 if (rn) sec.appendChild(el('div', 'spend-note spend-reprice', rn));
297
298 // The period toggle.
299 var toggle = el('div', 'spend-toggle');
300 ['week', 'month'].forEach(function (key) {
301 var p = [key];
302 var b = el('button', 'spend-toggle-btn' + (period === p[0] ? ' on' : ''), t('spend.period_' + key));
303 b.onclick = function () { period = p[0]; render(); };
304 toggle.appendChild(b);
305 });
306 sec.appendChild(toggle);
307
308 // The daily time graph.
309 var days = period === 'week' ? 7 : 30;
310 var ser = [];
311 try { ser = L.series(days) || []; } catch (e) { ser = []; }
312 var bars = ser.map(function (d) {
313 var dd = new Date(d.ts);
314 return { value: d.usd, label: dd.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) };
315 });
316 sec.appendChild(barChart(bars, { fmt: fmtUsd, empty: t('spend.no_turns') }));
317
318 // The by-model table.
319 var byModel = [];
320 try { byModel = L.perModel(period) || []; } catch (e) { byModel = []; }
321 if (byModel.length) {
322 var tbl = el('table', 'spend-table');
323 var thead = el('tr');
324 [t('spend.col_model'), t('spend.col_turns'), t('spend.col_tokens'), t('spend.col_cost')].forEach(function (h, i) {
325 var th = el('th', i > 0 ? 'num' : null, h); thead.appendChild(th);
326 });
327 var thd = el('thead'); thd.appendChild(thead); tbl.appendChild(thd);
328 var tb = el('tbody');
329 byModel.forEach(function (m) {
330 var tr = el('tr');
331 tr.appendChild(el('td', 'spend-model', m.model || t('spend.unknown_model')));
332 tr.appendChild(el('td', 'num', String(m.turns)));
333 tr.appendChild(el('td', 'num', fmtTokens(m.tokens)));
334 tr.appendChild(el('td', 'num', fmtUsd(m.usd)));
335 tb.appendChild(tr);
336 });
337 tbl.appendChild(tb);
338 sec.appendChild(tbl);
339 }
340
341 // What is left on each key, beside what this period drew from it. The
342 // credits row has had this since it existed; a user's own key had spend
343 // with nothing to measure it against, which is the half of the question
344 // that decides whether they can keep working.
345 var keys = providerKeys();
346 if (keys.length) {
347 sec.appendChild(el('div', 'spend-sub', t('spend.provider_keys')));
348 var ktbl = el('table', 'spend-table');
349 var khead = el('tr');
350 [t('spend.col_key'), t('spend.col_left'), t('spend.col_period_spend')].forEach(function (h, i) {
351 khead.appendChild(el('th', i > 0 ? 'num' : null, h));
352 });
353 var kthd = el('thead'); kthd.appendChild(khead); ktbl.appendChild(kthd);
354 var ktb = el('tbody');
355 keys.forEach(function (k) {
356 var tr = el('tr');
357 tr.appendChild(el('td', 'spend-model', k.name));
358 var left = el('td', 'num', k.left);
359 if (k.leftHint) left.title = k.leftHint;
360 tr.appendChild(left);
361 tr.appendChild(el('td', 'num', fmtUsd(k.spent)));
362 ktb.appendChild(tr);
363 });
364 ktbl.appendChild(ktb);
365 sec.appendChild(ktbl);
366 }
367 return sec;
368 }
369
370 // Each provider key, with what is left on it and what this period drew.
371 //
372 // A key nobody can say anything about shows an em dash, never a zero: "we do
373 // not know" and "you have nothing" are opposite messages and the second one
374 // would send a working user hunting for a top-up they do not need.
375 function providerKeys() {
376 var M = window.DaimondModels, L = window.DaimondLedger;
377 if (!M || typeof M.providers !== 'function' || !L
378 || typeof L.perProvider !== 'function') return [];
379 var from = Date.now() - (period === 'week' ? 7 : 30) * 24 * 60 * 60 * 1000;
380 var spentBy = {};
381 try {
382 L.perProvider(from).forEach(function (row) { spentBy[row.provider] = row.usd || 0; });
383 } catch (e) { spentBy = {}; }
384 var out = [];
385 try {
386 M.providers().forEach(function (p) {
387 var spent = spentBy[p.id] || 0;
388 // A row with neither a balance nor any spend has nothing to say.
389 if (!p.balance && spent <= 0) return;
390 out.push({
391 name: p.name,
392 left: p.balance || '—',
393 leftHint: p.creditMode === 'manual' ? t('spend.left_manual')
394 : p.creditMode === 'auto' ? t('spend.left_auto')
395 : p.minted ? '' : t('spend.left_unknown'),
396 spent: spent,
397 });
398 });
399 } catch (e) { return []; }
400 out.sort(function (a, b) { return b.spent - a.spent; });
401 return out;
402 }
403
404 function bigStat(value, label) {
405 var s = el('div', 'spend-stat');
406 s.appendChild(el('span', 'spend-stat-val', value));
407 s.appendChild(el('span', 'spend-stat-lbl', label));
408 return s;
409 }
410
411 // ── Credits section (from /api/ledger) ─────────────────────
412
413 function creditsSection() {
414 var sec = el('section', 'spend-sec');
415 sec.appendChild(sectionHead(t('spend.credits'), t('spend.credits_hint')));
416
417 var g = window.DaimondGateway;
418 var st = (g && g.state) ? g.state() : { authed: false };
419
420 if (!st.authed) {
421 var note = el('div', 'spend-note');
422 note.textContent = t('spend.no_account');
423 sec.appendChild(note);
424 return sec;
425 }
426
427 // Balance headline.
428 var head = el('div', 'spend-totals');
429 head.appendChild(bigStat(fmtCredits(st.credits || 0), t('spend.balance')));
430 sec.appendChild(head);
431
432 // Category breakdown of spends only (debits are negative deltas).
433 var byCat = {};
434 var movements = 0;
435 creditEntries.forEach(function (e) {
436 var d = e.delta_minor || 0;
437 if (d < 0) {
438 var c = e.category || 'other';
439 byCat[c] = (byCat[c] || 0) + (-d);
440 }
441 movements++;
442 });
443 var catRows = Object.keys(byCat)
444 .map(function (c) { return { label: catLabel(c), value: byCat[c] }; })
445 .sort(function (a, b) { return b.value - a.value; });
446 sec.appendChild(el('div', 'spend-sub', t('spend.where_credits_went')));
447 sec.appendChild(breakdown(catRows, fmtCredits));
448
449 // The movements table: the ledger itself, plainly.
450 if (movements) {
451 var tbl = el('table', 'spend-table');
452 var thead = el('tr');
453 [t('spend.col_when'), t('spend.col_what'), t('spend.col_amount'), t('spend.col_balance')].forEach(function (h, i) {
454 thead.appendChild(el('th', i > 1 ? 'num' : null, h));
455 });
456 var thd = el('thead'); thd.appendChild(thead); tbl.appendChild(thd);
457 var tb = el('tbody');
458 creditEntries.slice(0, 40).forEach(function (e) {
459 var tr = el('tr');
460 tr.appendChild(el('td', 'spend-when', fmtWhen(e.ts / 1e6))); // ts is ns
461 tr.appendChild(el('td', null, catLabel(e.category || e.kind)));
462 var d = e.delta_minor || 0;
463 var amt = el('td', 'num ' + (d < 0 ? 'debit' : 'credit'),
464 (d < 0 ? '−' : '+') + fmtCredits(Math.abs(d)));
465 tr.appendChild(amt);
466 tr.appendChild(el('td', 'num', fmtCredits(e.balance || 0)));
467 tb.appendChild(tr);
468 });
469 tbl.appendChild(tb);
470 sec.appendChild(tbl);
471 } else {
472 sec.appendChild(el('div', 'spend-empty', t('spend.no_movements')));
473 }
474 return sec;
475 }
476
477 // ── Render ─────────────────────────────────────────────────
478
479 function render() {
480 var host = document.getElementById('spend-view');
481 if (!host) return;
482 host.innerHTML = '';
483 // Frame the two pots before the numbers, so nobody reads them as one sum.
484 host.appendChild(el('div', 'spend-intro', t('spend.intro')));
485 host.appendChild(inferenceSection());
486 host.appendChild(el('div', 'spend-divider'));
487 host.appendChild(creditsSection());
488 // A meter is an estimate either way, but a converted one is an estimate
489 // twice over, so it says which rates it used.
490 if (window.DaimondI18n && DaimondI18n.currency() !== 'USD') {
491 host.appendChild(el('div', 'spend-note',
492 t('spend.rates_note', { date: DaimondI18n.ratesAsOf() })));
493 }
494 }
495
496 function wireActions() {
497 if (wiredActions) return;
498 var panel = document.getElementById('panel-spend');
499 if (!panel) return;
500 // The refresh button re-pulls and re-draws.
501 panel.addEventListener('click', function (ev) {
502 var b = ev.target.closest && ev.target.closest('[data-act="spend-refresh"]');
503 if (b) { ev.preventDefault(); onOpen(); }
504 });
505 // The header spend meter is the door to this view. Wire it once, at load,
506 // so it opens the panel whenever it is visible -- independently of when
507 // the meter's figures are (re)drawn.
508 var row = document.getElementById('spend-row');
509 if (row) {
510 row.setAttribute('role', 'button');
511 row.setAttribute('tabindex', '0');
512 row.title = t('spend.meter_help');
513 row.addEventListener('click', show);
514 row.addEventListener('keydown', function (ev) {
515 if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); show(); }
516 });
517 }
518 wiredActions = true;
519 }
520
521 /// Called when the panel is revealed: pull the freshest numbers, then draw.
522 /// The inference side is local and instant; the credit side is a fetch, so
523 /// the view renders at once and fills the credit table in when it lands.
524 async function onOpen() {
525 wireActions();
526 render(); // instant, from local data
527 var g = window.DaimondGateway;
528 if (g && g.state && g.state().authed) {
529 try { await g.refreshBalance(); } catch (e) {}
530 try { creditEntries = await g.ledger(); } catch (e) { creditEntries = []; }
531 render(); // redraw with credit history
532 }
533 }
534
535 /// Open the Spending panel (used by the header meter's click).
536 ///
537 /// When the panel is already open, `DaimondPanels.show` is a no-op and its
538 /// render hook does not fire, so refresh explicitly in that case -- a click
539 /// on the meter always leaves a freshly-drawn view.
540 function show() {
541 var P = window.DaimondPanels;
542 var wasOpen = !!(P && P.isOpen && P.isOpen('spend'));
543 if (P) P.show('spend'); else onOpen();
544 if (wasOpen) onOpen();
545 }
546
547 window.DaimondSpend = {
548 onOpen: onOpen,
549 refresh: onOpen,
550 show: show,
551 };
552
553 // A language or currency change redraws the panel if it is on screen; a
554 // closed one is rebuilt from scratch when it next opens.
555 if (window.DaimondI18n) {
556 DaimondI18n.onChange(function () {
557 if (document.getElementById('spend-view')) render();
558 });
559 }
560
561 // Wire the header meter at load, so it opens the view the first time it is
562 // clicked, before the panel has ever been shown.
563 if (document.readyState === 'loading') {
564 document.addEventListener('DOMContentLoaded', wireActions);
565 } else {
566 wireActions();
567 }
568})();