Download static/app.js from s-ttp/crucible: direct link, hf CLI and curl.
- Browser
- Download file 21.9 kB
-
https://huggingface.co/spaces/s-ttp/crucible/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/s-ttp/crucible/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/s-ttp/crucible/resolve/main/static/app.js
21.9 kB
| /* Crucible front end. The page shows progress while a run is in flight and then the report the | |
| engine audited, exactly as delivered (the same fragments as the markdown download). It | |
| computes no figures, ratings or bands of its own. Progress events say which step is running | |
| and who has finished, never what anyone concluded. */ | |
| const $ = (s, el = document) => el.querySelector(s); | |
| const $$ = (s, el = document) => [...el.querySelectorAll(s)]; | |
| const escapeHtml = s => String(s ?? "").replace(/[&<>"']/g, | |
| c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); | |
| const BASE_TITLE = "Crucible"; | |
| async function loadHealth() { | |
| const h = await fetch("/api/health").then(r => r.json()).catch(() => null); | |
| const b = $("#env-badge"); | |
| if (!h) { b.textContent = "offline"; return; } | |
| const llm = Object.values(h.llm).filter(Boolean).length; | |
| b.textContent = h.engine ? `ready · ${llm}/4 analysts` : "engine unavailable"; | |
| if (!h.engine || !h.data || llm < 4) b.classList.add("warn"); | |
| } | |
| /* ── progress model ───────────────────────────────────────────────────────────────────── | |
| Milestones are the engine's stage percentages; START is when each typically begins (seconds, | |
| from recent runs). Between milestones the bar eases toward the next one, never past it. */ | |
| const START = { 0: 0, 4: 2, 14: 10, 20: 14, 30: 16, 40: 24, 50: 90, 60: 92, 66: 94, 74: 124, 82: 140, 88: 150, 92: 178, 96: 180 }; | |
| const TYPICAL_END = 195; | |
| const MILESTONES = Object.keys(START).map(Number).sort((a, b) => a - b); | |
| const phaseOf = pct => pct >= 92 ? "report" : pct >= 66 ? "debate" : pct >= 40 ? "valuation" : "data"; | |
| const EXPLAIN = { | |
| queue: [["In the queue", "Another analysis is running. Yours starts as soon as it finishes; one run at a time keeps the data sources within their limits."]], | |
| data: [ | |
| ["Checking the numbers first", "Reported revenue, margins and share counts are reconciled against the company's own SEC filings before anything is valued."], | |
| ["Why this matters", "Data providers mix currencies, share classes and restated periods. A figure that can't be reconciled is flagged, not guessed."], | |
| ], | |
| valuation: [ | |
| ["Stories to numbers", "Following Aswath Damodaran's framework, three AI storytellers each describe the business: market size, share, margins and reinvestment. A cash-flow model turns each story into a value."], | |
| ["The middle story counts", "The value is the median of the three stories, so one optimistic or gloomy storyteller can't move it."], | |
| ["One business at a time", "When a company's filed segments have very different economics, each storyteller must value them separately, as Damodaran did with SpaceX."], | |
| ["Where risk comes from", "The discount rate comes from Damodaran's own industry datasets and his monthly equity risk premium, not from the storytellers."], | |
| ], | |
| debate: [ | |
| ["Four models, four labs", "Four different AI models take the neutral, bear, bull and portfolio-manager roles, so the debate is not one model arguing with itself."], | |
| ["Same facts for everyone", "Every analyst sees the same validated figures. They can disagree about what matters, not about the numbers."], | |
| ["Rebuttals", "The bear and the bull answer each other's strongest points before the portfolio manager assigns probabilities."], | |
| ], | |
| report: [["Nothing unverified gets published", "Every figure in the report is checked against the frozen analysis. If one doesn't match, the report is withheld rather than patched."]], | |
| }; | |
| const P = { pct: 0, since: 0, t0: 0, timer: null, phase: null, card: 0, cardAt: 0, ticker: "", done: false, active: false, | |
| facts: null, hood: null, hoodTimer: null }; | |
| /* ── under the hood: what the framework is doing with THIS company's inputs (never a result) ── */ | |
| const STAGE_WORDS = { | |
| "young-growth": ["young growth", "the story centres on how big the market can get and whether the company survives to reach it"], | |
| "high-growth": ["high growth", "growth lasts for years, reinvestment is heavy and margins are still climbing"], | |
| "mature-growth": ["mature growth", "growth slows toward the economy's while margins settle near their peak"], | |
| "mature-stable": ["mature and stable", "cash flow and returns on capital matter more than growth"], | |
| "decline": ["decline", "shrinking revenue releases capital, and nothing grows forever"], | |
| }; | |
| const monthWords = iso => { const d = new Date(`${String(iso).slice(0, 10)}T00:00:00Z`); | |
| return isNaN(d) ? String(iso || "") : d.toLocaleString("en-US", { month: "long", year: "numeric", timeZone: "UTC" }); }; | |
| const pct1 = x => `${(x * 100).toFixed(1)}%`; | |
| function valuationHood(f) { | |
| const it = []; | |
| if (f.industry) it.push(["Industry", `Damodaran's company data places ${P.ticker} in ${f.industry}. His industry averages anchor the story's margins and reinvestment.`]); | |
| if (f.crp > 0 && f.country) it.push(["Country risk", `Operating in ${f.country} adds ${pct1(f.crp)} to the equity risk premium, from his country risk table.`]); | |
| if (f.beta != null) it.push(["Risk", f.beta_basis === "business mix" | |
| ? `Beta ${f.beta.toFixed(2)}: the industry betas of its businesses, weighted by what each business is worth, re-levered at its debt.` | |
| : `Beta ${f.beta.toFixed(2)}: the ${f.industry || "industry"} industry's beta, re-levered at the company's own debt.`]); | |
| if (f.erp != null) it.push(["Market premium", `${pct1(f.erp)} equity risk premium, Damodaran's implied figure for ${monthWords(f.erp_month)}.`]); | |
| const st = STAGE_WORDS[f.stage]; | |
| if (st) it.push(["Life cycle", `${st[0][0].toUpperCase() + st[0].slice(1)}: ${st[1]}.`]); | |
| if ((f.segments || []).length > 1) it.push(["Businesses", f.segment_build | |
| ? `${f.segments.join(", ")} have different economics, so each storyteller must value them one by one.` | |
| : `${f.segments.join(", ")} are valued together; their economics are close enough.`]); | |
| if (f.consensus) it.push(["Near term", "The first two years lean on analysts' revenue estimates; after that, growth fades toward the economy's."]); | |
| if (f.ml_rank === "LIMITED" || f.ml_rank === "VALIDATED") it.push(["Machine learning", "A model trained on twelve years of data ranks the stock against the largest US companies. Its test record makes it supporting evidence for the analysts; it does not set the rating."]); | |
| else if (f.ml_rank === "UNRELIABLE") it.push(["Machine learning", "A model that ranks the stock against the largest US companies was checked and switched off for now: its recent test record is too weak to rely on."]); | |
| it.push(["Storytellers", "Three different AI models are each writing a story at the same time: market size, share, margins and reinvestment."]); | |
| it.push(["Cash flows", "Each story becomes ten years of cash flow, discounted at today's rate for five years and then at a rate drifting toward a mature company's."]); | |
| it.push(["The value", "The middle of the three values is adopted, so no single storyteller sets it."]); | |
| return it; | |
| } | |
| const DEBATE_ROUNDS = [ | |
| [66, "Round 1", "The neutral analyst sets a baseline from the validated figures."], | |
| [74, "Round 2", "The bear and the bull build their cases on the same facts."], | |
| [82, "Round 3", "Each answers the other's strongest point."], | |
| [88, "Round 4", "The portfolio manager weighs the cases and assigns probabilities."], | |
| ]; | |
| function showHood(kind) { | |
| const hood = $("#hood"), list = $("#hood-list"); | |
| clearInterval(P.hoodTimer); | |
| if (kind === "valuation" && P.facts) { | |
| P.hood = kind; | |
| $("#hood-title").textContent = "· Damodaran's framework"; | |
| const items = valuationHood(P.facts); | |
| list.innerHTML = ""; | |
| let i = 0; | |
| const add = () => { | |
| if (i >= items.length) { clearInterval(P.hoodTimer); return; } | |
| list.querySelectorAll("li.latest").forEach(li => li.classList.remove("latest")); | |
| list.insertAdjacentHTML("beforeend", `<li class="in latest"><b>${escapeHtml(items[i][0])}</b> ${escapeHtml(items[i][1])}</li>`); | |
| i += 1; | |
| }; | |
| add(); P.hoodTimer = setInterval(add, 2600); | |
| } else if (kind === "debate") { | |
| P.hood = kind; | |
| $("#hood-title").textContent = "· the analyst debate"; | |
| list.innerHTML = DEBATE_ROUNDS.map(([p, r, t]) => `<li data-at="${p}"><b>${r}</b> ${escapeHtml(t)}</li>`).join(""); | |
| markRound(); | |
| } else { | |
| P.hood = null; | |
| } | |
| hood.hidden = !P.hood; | |
| $("#wait-card").hidden = !!P.hood; | |
| } | |
| function markRound() { | |
| $$("#hood-list li[data-at]").forEach((li, i, all) => { | |
| const at = Number(li.dataset.at), next = all[i + 1] ? Number(all[i + 1].dataset.at) : 92; | |
| li.classList.toggle("done", P.pct >= next); | |
| li.classList.toggle("latest", P.pct >= at && P.pct < next); | |
| }); | |
| } | |
| function fmtClock(s) { s = Math.max(0, Math.round(s)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; } | |
| function shownPct(now) { | |
| const i = MILESTONES.indexOf(P.pct); | |
| const next = i >= 0 && i < MILESTONES.length - 1 ? MILESTONES[i + 1] : 100; | |
| const dur = Math.max(3, (START[next] ?? TYPICAL_END) - (START[P.pct] ?? 0)); | |
| const t = (now - P.since) / 1000; | |
| return P.pct + (next - P.pct) * 0.9 * (1 - Math.exp(-t / dur)); | |
| } | |
| function remainingText(now) { | |
| const elapsedInStage = (now - P.since) / 1000; | |
| const left = TYPICAL_END - (START[P.pct] ?? 0) - elapsedInStage; | |
| if (P.phase === "queue") return "waiting"; | |
| if (left > 90) return `about ${Math.round(left / 60)} min left`; | |
| if (left > 25) return "about a minute left"; | |
| return "almost done"; | |
| } | |
| function tick() { | |
| if (P.done) return; | |
| const now = Date.now(); | |
| const pct = P.phase === "queue" ? 1 : shownPct(now); | |
| $("#progress").style.width = `${pct.toFixed(1)}%`; | |
| $("#mini-fill").style.width = `${pct.toFixed(1)}%`; | |
| const clock = `${fmtClock((now - P.t0) / 1000)} · ${remainingText(now)}`; | |
| $("#run-clock").textContent = clock; | |
| $("#mini-text").textContent = `${P.ticker} · ${$("#progress-label").textContent} · ${fmtClock((now - P.t0) / 1000)}`; | |
| document.title = `${Math.round(pct)}% · ${P.ticker} · ${BASE_TITLE}`; | |
| if (!P.hood && now - P.cardAt > 14000) nextCard(); | |
| } | |
| function nextCard() { | |
| const cards = EXPLAIN[P.phase] || EXPLAIN.data; | |
| const [h, p] = cards[P.card % cards.length]; | |
| const box = $("#wait-card"); | |
| box.classList.remove("in"); void box.offsetWidth; box.classList.add("in"); | |
| $("#wait-h").textContent = h; $("#wait-p").textContent = p; | |
| P.card += 1; P.cardAt = Date.now(); | |
| } | |
| function setPhase(phase) { | |
| if (phase === P.phase) return; | |
| P.phase = phase; P.card = 0; nextCard(); | |
| const order = ["data", "valuation", "debate", "report"]; | |
| const at = order.indexOf(phase); | |
| $$("#steps li").forEach(li => { | |
| const k = order.indexOf(li.dataset.phase); | |
| li.classList.toggle("is-done", at > k); | |
| li.classList.toggle("is-now", at === k); | |
| }); | |
| if (phase === "debate") $('#seats [data-role="neutral"]').classList.add("thinking"); | |
| showHood(phase); | |
| } | |
| function onStage(ev) { | |
| if (ev.step === "queue") { P.phase = null; setPhase("queue"); $("#progress-label").textContent = ev.label; return; } | |
| if (ev.pct > P.pct || P.pct === 0) { P.pct = ev.pct; P.since = Date.now(); } | |
| $("#progress-label").textContent = ev.label; | |
| setPhase(phaseOf(ev.pct)); | |
| const seat = r => $(`#seats [data-role="${r}"]`); | |
| if (ev.pct === 74 || ev.pct === 82) ["bear", "bull"].forEach(r => { if (!seat(r).classList.contains("done")) seat(r).classList.add("thinking"); }); | |
| if (ev.pct === 88) { ["neutral", "bear", "bull"].forEach(r => seat(r).classList.remove("thinking")); if (!seat("pm").classList.contains("done")) seat("pm").classList.add("thinking"); } | |
| if (ev.pct >= 92) $$("#seats em").forEach(e => e.classList.remove("thinking")); | |
| if (P.hood === "debate") markRound(); | |
| } | |
| function onFacts(ev) { | |
| P.facts = ev.facts || null; | |
| if (P.phase === "valuation") showHood("valuation"); | |
| } | |
| function onStory(ev) { | |
| $$("#story-pips em").forEach((e, i) => e.classList.toggle("on", i < ev.n)); | |
| } | |
| function onSeat(ev) { | |
| const el = $(`#seats [data-role="${ev.role}"]`); | |
| if (!el) return; | |
| el.classList.remove("thinking"); | |
| el.classList.add(ev.phase === "case" ? "spoke" : "done"); | |
| } | |
| function startProgress(ticker) { | |
| Object.assign(P, { pct: 0, since: Date.now(), t0: Date.now(), phase: null, card: 0, cardAt: 0, ticker, done: false, active: true, | |
| facts: null, hood: null }); | |
| clearInterval(P.hoodTimer); | |
| $$("#steps li").forEach(li => li.classList.remove("is-done", "is-now")); | |
| $$("#story-pips em").forEach(e => e.classList.remove("on")); | |
| $$("#seats em").forEach(e => e.classList.remove("thinking", "spoke", "done")); | |
| setPhase("data"); | |
| clearInterval(P.timer); | |
| P.timer = setInterval(tick, 500); | |
| tick(); | |
| } | |
| function stopProgress() { | |
| P.done = true; P.active = false; | |
| clearInterval(P.timer); clearInterval(P.hoodTimer); | |
| $("#progress").style.width = "100%"; | |
| $("#mini-run").hidden = true; | |
| document.title = BASE_TITLE; | |
| } | |
| // a slim progress pill for phones once the progress panel scrolls out of view | |
| const miniObserver = "IntersectionObserver" in window ? new IntersectionObserver(entries => { | |
| const vis = entries[0].isIntersecting; | |
| $("#mini-run").hidden = vis || !P.active; | |
| }) : null; | |
| if (miniObserver) miniObserver.observe($("#runbar")); | |
| $("#mini-run").addEventListener("click", () => $("#runbar").scrollIntoView({ behavior: "smooth", block: "center" })); | |
| /* ── notifications (only when the browser supports them and the user asks) ─────────────── */ | |
| function setupNotify() { | |
| const b = $("#notify-btn"); | |
| b.hidden = !("Notification" in window) || Notification.permission === "denied" || Notification.permission === "granted"; | |
| b.onclick = async () => { | |
| const res = await Notification.requestPermission().catch(() => "denied"); | |
| b.textContent = res === "granted" ? "We'll notify you" : "Notifications are blocked"; | |
| b.disabled = true; | |
| }; | |
| } | |
| function notifyDone(ticker, published) { | |
| try { | |
| if ("Notification" in window && Notification.permission === "granted" && document.visibilityState !== "visible") | |
| new Notification(`${ticker} analysis ${published ? "is ready" : "finished"}`, { body: published ? "Tap to read the report." : "The report was not published." }); | |
| } catch { /* optional */ } | |
| if (navigator.vibrate) navigator.vibrate(60); | |
| } | |
| /* ── the previous published report, readable while the new one runs ──────────────────── */ | |
| async function offerPrevious(ticker) { | |
| const btn = $("#prev-btn"); | |
| btn.hidden = true; | |
| const r = await fetch(`/api/report/${encodeURIComponent(ticker)}`).catch(() => null); | |
| if (!r || !r.ok || P.done) return; | |
| const d = await r.json().catch(() => null); | |
| if (!d || d.status !== "PUBLISHED") return; | |
| const when = ageWords(d.cache_age_days); | |
| btn.textContent = `Meanwhile, read the analysis published ${when}`; | |
| btn.hidden = false; | |
| btn.onclick = () => { | |
| const box = $("#prev-report"); | |
| box.innerHTML = `<p class="prev-note">Previous analysis, published ${escapeHtml(when)}. The new one replaces it when it is ready.</p>` + d.html; | |
| box.className = ratingClass(d.rating); | |
| box.hidden = false; | |
| btn.hidden = true; | |
| box.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| }; | |
| } | |
| function ageWords(days) { | |
| if (days == null) return "earlier"; | |
| if (days <= 0.5) return "today"; | |
| if (days < 1.5) return "yesterday"; | |
| return `${Math.round(days)} days ago`; | |
| } | |
| function ratingClass(rt) { | |
| const r = String(rt || "").toUpperCase(); | |
| return "rated " + (/STRONG BUY/.test(r) ? "r-strongbuy" : /BUY/.test(r) ? "r-buy" : /STRONG SELL/.test(r) ? "r-strongsell" | |
| : /SELL/.test(r) ? "r-sell" : "r-hold"); | |
| } | |
| /* ── views ─────────────────────────────────────────────────────────────────────────────── */ | |
| function resetView(ticker) { | |
| document.body.classList.add("has-analysis"); | |
| $("#analysis").hidden = false; | |
| $("#v-ticker").hidden = false; | |
| $("#v-ticker").textContent = ticker; | |
| $("#status").textContent = "starting…"; | |
| $("#report").innerHTML = ""; $("#report").className = ""; | |
| $("#prev-report").innerHTML = ""; $("#prev-report").hidden = true; | |
| $("#notice").hidden = true; $("#notice").textContent = ""; | |
| $("#actions").hidden = true; | |
| $("#runbar").hidden = false; | |
| $("#prev-btn").hidden = true; | |
| $("#progress").style.width = "1%"; | |
| $("#progress-label").textContent = "Starting…"; | |
| } | |
| function showNotice(text) { | |
| $("#notice").textContent = text; | |
| $("#notice").hidden = false; | |
| } | |
| function showReport(rep) { | |
| stopProgress(); | |
| $("#runbar").hidden = true; | |
| $("#prev-report").hidden = true; $("#prev-report").innerHTML = ""; | |
| if (!rep || rep.status !== "PUBLISHED") { | |
| $("#status").textContent = rep && rep.status === "WITHHELD" ? "analysis withheld" : "analysis unavailable"; | |
| showNotice((rep && rep.message) || "The analysis could not be completed. Please try again later."); | |
| return; | |
| } | |
| $("#v-ticker").hidden = true; // the report's own title names the company | |
| $("#report").className = ratingClass(rep.rating); | |
| $("#report").innerHTML = rep.html; // audited document, rendered and escaped by the engine | |
| $("#md-btn").href = `/api/report/${encodeURIComponent(rep.ticker)}/md`; | |
| $("#actions").hidden = false; | |
| if (rep.stale) showNotice(rep.stale); | |
| } | |
| async function run() { | |
| const query = $("#ticker").value.trim(); | |
| if (!query) return; | |
| resetView(query.toUpperCase()); | |
| $("#run").disabled = true; | |
| $("#ticker").blur(); | |
| $("#analysis").scrollIntoView({ behavior: "smooth", block: "start" }); | |
| const resp = await fetch(`/api/analyst/${encodeURIComponent(query)}`, { method: "POST", headers: { "X-Crucible-Client": "ui" } }); | |
| if (!resp.ok) { | |
| let msg = "The analysis could not be started."; | |
| try { const e = await resp.json(); if (e.detail) msg = e.detail; } catch { /* keep default */ } | |
| $("#status").textContent = "not started"; | |
| $("#runbar").hidden = true; | |
| showNotice(msg); | |
| $("#run").disabled = false; | |
| return; | |
| } | |
| const start = await resp.json(); | |
| const ticker = start.ticker || query.toUpperCase(); | |
| $("#v-ticker").textContent = ticker; | |
| $("#status").textContent = start.resolved | |
| ? `${start.resolved.name || ticker} · resolved from “${start.resolved.from}”` | |
| : "analysis running"; | |
| startProgress(ticker); | |
| setupNotify(); | |
| offerPrevious(ticker); | |
| const es = new EventSource(`/api/analyst/${start.job_id}/stream`); | |
| es.onmessage = e => { | |
| try { | |
| const ev = JSON.parse(e.data); | |
| if (ev.t === "stage") onStage(ev); | |
| else if (ev.t === "story") onStory(ev); | |
| else if (ev.t === "seat") onSeat(ev); | |
| else if (ev.t === "facts") onFacts(ev); | |
| } catch { /* ignore malformed event */ } | |
| }; | |
| es.addEventListener("done", async () => { | |
| es.close(); | |
| $("#run").disabled = false; | |
| const rep = await fetch(`/api/analyst/${start.job_id}/result`).then(r => r.json()).catch(() => null); | |
| const ok = rep && rep.status === "PUBLISHED"; | |
| $("#status").innerHTML = ok ? `published just now · <a href="#" id="rerun-live">run again ›</a>` : $("#status").textContent; | |
| $("#rerun-live")?.addEventListener("click", e => { e.preventDefault(); $("#ticker").value = ticker; run(); }); | |
| notifyDone(ticker, ok); | |
| showReport(rep); | |
| $("#analysis").scrollIntoView({ behavior: "smooth", block: "start" }); | |
| }); | |
| es.onerror = () => { /* keepalive gaps are fine; done closes the stream */ }; | |
| } | |
| async function showCached(ticker) { | |
| resetView(ticker); | |
| $("#runbar").hidden = true; | |
| $("#analysis").scrollIntoView({ behavior: "smooth", block: "start" }); | |
| const r = await fetch(`/api/report/${encodeURIComponent(ticker)}`); | |
| if (!r.ok) { | |
| $("#status").innerHTML = `no recent published analysis of ${escapeHtml(ticker)} · <a href="#" id="rerun-live">run a new one ›</a>`; | |
| $("#rerun-live")?.addEventListener("click", e => { e.preventDefault(); $("#ticker").value = ticker; run(); }); | |
| return; | |
| } | |
| const d = await r.json(); | |
| $("#status").innerHTML = `published ${escapeHtml(ageWords(d.cache_age_days))} · <a href="#" id="rerun-live">run a new analysis ›</a>`; | |
| $("#rerun-live")?.addEventListener("click", e => { e.preventDefault(); $("#ticker").value = d.ticker; run(); }); | |
| showReport(d); | |
| } | |
| function bindChips() { | |
| $$(".ex-chip").forEach(c => c.addEventListener("click", () => { | |
| if (c.dataset.cached === "1") { showCached(c.dataset.t); return; } | |
| $("#ticker").value = c.dataset.t; run(); | |
| })); | |
| } | |
| $("#run").addEventListener("click", run); | |
| $("#ticker").addEventListener("keydown", e => { if (e.key === "Enter") run(); }); | |
| $("#print-btn").addEventListener("click", () => window.print()); | |
| // printed reports show every section | |
| window.addEventListener("beforeprint", () => $$("#report details").forEach(d => { d.dataset.wasOpen = d.open ? "1" : ""; d.open = true; })); | |
| window.addEventListener("afterprint", () => $$("#report details").forEach(d => { d.open = d.dataset.wasOpen === "1"; })); | |
| (async function recentChips() { | |
| try { | |
| const r = await fetch("/api/recent").then(x => x.json()); | |
| const rows = (r.recent || []).slice(0, 4); | |
| if (!rows.length) return; | |
| const cls = rt => /BUY/i.test(rt || "") ? "up" : /SELL/i.test(rt || "") ? "down" : "flat"; | |
| $("#examples").innerHTML = `<span class="ex-label">Recent</span>` + rows.map(x => | |
| `<button type="button" class="ex-chip ex-cached" data-t="${escapeHtml(x.ticker)}" data-cached="1" | |
| title="published ${escapeHtml(x.age_days)}d ago, opens instantly">${escapeHtml(x.ticker)} | |
| <em class="${cls(x.rating)}">${escapeHtml(x.rating || "")}</em></button>` | |
| ).join(""); | |
| } catch (e) { /* static chips stay */ } | |
| finally { bindChips(); } | |
| })(); | |
| loadHealth(); | |