diff --git a/tests/js/README.md b/tests/js/README.md
index c51de48..5b4e2ac 100644
--- a/tests/js/README.md
+++ b/tests/js/README.md
@@ -15,11 +15,40 @@ Chromium is driven headless with `--no-sandbox --disable-dev-shm-usage`.
## Gates
-### `npm run e2e` — end-to-end responsiveness (primary)
+### `npm run test:dashboard` — production-scale dashboard gate (primary)
+
+`dashboard_e2e.mjs` boots `seed_and_serve.py` (a Flask server on a throwaway
+SQLite DB seeded to a **production shape**: ~3300-node taxonomy, all 9 metrics
+non-zero, *heavy* `/api/deep_insights` ≈ 0.6 MB and `/api/insights` ≈ 0.7 MB
+payloads, and a dense 29×14 benchmark matrix on the `ml.bench` leaf), drives the
+real dashboard in Chromium under **4× CPU throttling** (Lighthouse's mobile
+profile — so the 200ms budget is measured against a realistic device, not the
+fast CI box), and asserts in one user-shaped pass:
+
+- **First paint ≤ 2s**: all 9 overview stat cards show real numbers.
+- **All 12 tabs** open on production-weight data, each staying interactive.
+- **Heatmap (Acceptance B)**: the Evidence matrix has **> 1 distinct fill
+ colour** (a real value gradient) and stays a heatmap after a metric switch.
+- **Graph zoom/drag, search, language switch** all work.
+- **60s idle + everything above**: NO main-thread long task > 200ms, the page
+ stays interactive, console reports no error.
-`responsiveness_e2e.mjs` boots `seed_and_serve.py` (a Flask server on a throwaway
-SQLite DB seeded to a production shape: a ~3300-node taxonomy **tree** and all 9
-overview metrics non-zero), drives the real dashboard in Chromium, and asserts:
+```bash
+IDLE_MS=6000 node dashboard_e2e.mjs # faster iteration
+CPU_THROTTLE=6 node dashboard_e2e.mjs # low-end-device profile
+```
+
+### `npm run test:render-perf` — render microbenchmarks (Acceptance A fallback)
+
+`dashboard_render_perf.mjs` runs the heatmap colour scale and the chunked list
+builder at **N=5000** in real Chromium and asserts the fast (O(n)) path is
+< 200ms while a deliberately O(n²) reference is many times slower — so the
+harness would catch a quadratic regression.
+
+### `npm run test:responsiveness` — load-responsiveness gate
+
+`responsiveness_e2e.mjs` boots the same `seed_and_serve.py`, drives the real
+dashboard in Chromium, and asserts:
- **First paint ≤ 2s**: all 9 overview stat cards show real (non-zero) numbers.
- **After load**: idle `IDLE_MS` (default 60000), click every tab, switch language
@@ -35,7 +64,7 @@ IDLE_MS=6000 node responsiveness_e2e.mjs
This test **fails on the pre-fix code** (the O(n²) taxonomy-dropdown build froze
the main thread for seconds during the idle prefetch) and **passes on the fix**.
-### `npm run perf` — deterministic perf microbenchmark
+### `npm run test:dropdown-perf` — deterministic perf microbenchmark
`taxonomy_dropdown_perf.mjs` builds the taxonomy option list inside real Chromium
two ways and asserts the production path (DocumentFragment, single attach) is fast
diff --git a/tests/js/dashboard_e2e.mjs b/tests/js/dashboard_e2e.mjs
new file mode 100644
index 0000000..81294d0
--- /dev/null
+++ b/tests/js/dashboard_e2e.mjs
@@ -0,0 +1,260 @@
+// Comprehensive production-scale dashboard gate (Acceptance A + B + C).
+//
+// Boots seed_and_serve.py — a throwaway DB seeded to a PRODUCTION shape:
+// ~3300-node taxonomy, all 9 overview metrics non-zero, *heavy* deep_insights
+// (~0.6 MB /api/deep_insights?limit=50) and insights (~0.7 MB) payloads, and a
+// dense 29×14 benchmark matrix on the BENCH_NODE leaf. Then drives the real
+// dashboard in headless Chromium and asserts, all in one user-shaped pass:
+//
+// A (perf): first paint ≤ 2s with 9 real numbers; then open every tab, render
+// the heavy Evidence matrix, zoom/drag the knowledge graph, search, switch
+// language, and idle IDLE_MS — with NO main-thread long task > 200ms, the
+// page staying interactive, and zero console/page errors throughout.
+// B (heatmap): the Evidence matrix is a real heatmap — its filled cells carry
+// MORE THAN ONE distinct background colour (a gradient), and switching the
+// metric keeps it a heatmap.
+// C (behaviour): all 6 main tabs + the Advanced nav + the graph + language +
+// search all keep working on production-weight data.
+//
+// Env: IDLE_MS (default 60000), HEADLESS (default 1), PORT (default 5098).
+import { chromium } from "playwright";
+import { spawn } from "node:child_process";
+import { once } from "node:events";
+import { createInterface } from "node:readline";
+import { appendFileSync, writeFileSync } from "node:fs";
+
+const REPO_ROOT = new URL("../../", import.meta.url).pathname;
+const PY = `${REPO_ROOT}.venv/bin/python`;
+const SERVER = `${REPO_ROOT}tests/js/seed_and_serve.py`;
+const PORT = parseInt(process.env.PORT || "5098", 10);
+const IDLE_MS = parseInt(process.env.IDLE_MS || "60000", 10);
+const FIRST_PAINT_BUDGET_MS = 2000;
+const LONGTASK_BUDGET_MS = 200;
+const BENCH_NODE = "ml.bench";
+
+const LOG = process.env.E2E_LOG || `${REPO_ROOT}tests/js/dashboard_e2e_result.log`;
+try { writeFileSync(LOG, ""); } catch { /* ignore */ }
+const line = (s) => { try { appendFileSync(LOG, s + "\n"); } catch { /* ignore */ } console.log(s); };
+
+const fails = [];
+const check = (cond, msg) => { if (!cond) { fails.push(msg); line(` FAIL ${msg}`); } };
+const ok = (m) => line(` ok ${m}`);
+
+const STAT_IDS = [
+ "statPapers", "statResults", "statTaxonomy", "statContradictions",
+ "statInsights", "statTokens", "statExperiments", "statDeepDiscoveries",
+ "statCompletePapers",
+];
+const TABS = [
+ "overview", "explore", "evidence", "generated-papers", "insights", "papers",
+ "paper-progress", "discoveries", "experiments", "feed", "providers", "agenda",
+];
+
+line("dashboard_e2e.mjs");
+const server = spawn(PY, [SERVER, String(PORT)], { cwd: REPO_ROOT });
+let serverReady = false;
+const rl = createInterface({ input: server.stdout });
+rl.on("line", (l) => { if (l.startsWith("READY")) serverReady = true; });
+server.stderr.on("data", () => { /* werkzeug request log noise */ });
+
+async function waitFor(pred, timeoutMs, label) {
+ const t0 = Date.now();
+ while (Date.now() - t0 < timeoutMs) {
+ if (pred()) return;
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ throw new Error(`timeout waiting for ${label}`);
+}
+
+let browser;
+try {
+ await waitFor(() => serverReady, 60000, "server READY");
+ browser = await chromium.launch({
+ headless: process.env.HEADLESS !== "0",
+ args: ["--no-sandbox", "--disable-dev-shm-usage"],
+ });
+ const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
+
+ // Emulate a real (slower) user device. A long task is a function of CPU
+ // speed, and the CI/dev box is far faster than a typical user's laptop, so
+ // an unthrottled run "passes" work that janks for real users — exactly the
+ // "fast-CPU false pass" trap. Lighthouse uses 4× CPU slowdown for its mobile
+ // profile; we default to 4× so the 200ms budget is measured against a
+ // realistic main thread, not the test machine's.
+ const CPU_THROTTLE = parseFloat(process.env.CPU_THROTTLE || "4");
+ if (CPU_THROTTLE > 1) {
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_THROTTLE });
+ line(` CPU throttling: ${CPU_THROTTLE}× (emulating a real user device)`);
+ }
+
+ await page.addInitScript(() => {
+ window.__longtasks = [];
+ window.__phase = "load";
+ try {
+ new PerformanceObserver((list) => {
+ for (const e of list.getEntries()) {
+ window.__longtasks.push({ duration: e.duration, startTime: e.startTime, phase: window.__phase });
+ }
+ }).observe({ entryTypes: ["longtask"], buffered: true });
+ } catch { window.__longtaskUnsupported = true; }
+ });
+ const setPhase = (p) => page.evaluate((x) => { window.__phase = x; }, p);
+
+ const consoleErrors = [];
+ const pageErrors = [];
+ page.on("console", (m) => {
+ if (m.type() !== "error") return;
+ const text = m.text();
+ const url = (m.location() && m.location().url) || "";
+ if (/favicon/i.test(url) || /Failed to load resource/i.test(text)) return; // resource 404 != JS fault
+ consoleErrors.push(text);
+ });
+ page.on("pageerror", (e) => pageErrors.push(String(e)));
+
+ // ── 1) first paint ≤ 2s with 9 real numbers ───────────────────────────
+ const t0 = Date.now();
+ await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: "commit" });
+ await page.waitForFunction(
+ (ids) => ids.every((id) => {
+ const el = document.getElementById(id);
+ const txt = el && el.textContent.trim();
+ return txt && txt !== "0";
+ }),
+ STAT_IDS,
+ { timeout: 10000 }
+ );
+ const firstPaintMs = Date.now() - t0;
+ const statValues = await page.evaluate(
+ (ids) => Object.fromEntries(ids.map((id) => [id, document.getElementById(id).textContent.trim()])),
+ STAT_IDS
+ );
+ check(firstPaintMs <= FIRST_PAINT_BUDGET_MS, `first paint ${firstPaintMs}ms > ${FIRST_PAINT_BUDGET_MS}ms`);
+ for (const id of STAT_IDS) check(statValues[id] && statValues[id] !== "0", `${id} not real: ${statValues[id]}`);
+ ok(`first paint (9 real cards) in ${firstPaintMs}ms: ${Object.values(statValues).join(" / ")}`);
+
+ // ── 2) open every tab (heavy renders) ──────────────────────────────────
+ await page.evaluate(() => { const d = document.querySelector("details.advanced-nav"); if (d) d.open = true; });
+ for (const tab of TABS) {
+ const btn = await page.$(`[data-tab="${tab}"]`);
+ check(!!btn, `tab button missing: ${tab}`);
+ if (!btn) continue;
+ await setPhase(`tab:${tab}`);
+ const ti = Date.now();
+ await btn.click();
+ try {
+ await page.waitForFunction((t) => document.getElementById("tab-" + t)?.classList.contains("active"), tab, { timeout: 3000 });
+ } catch { check(false, `tab '${tab}' did not activate within 3s`); }
+ check(Date.now() - ti <= 1500, `tab '${tab}' switch took ${Date.now() - ti}ms (janky)`);
+ }
+ ok(`opened ${TABS.length} tabs on production-weight data`);
+
+ // ── 3) Evidence matrix → real heatmap (Acceptance B) ───────────────────
+ await setPhase("evidence-matrix");
+ await page.click('[data-tab="evidence"]');
+ const tMatrix = Date.now();
+ await page.evaluate((node) => {
+ const inp = document.getElementById("evidenceNodeSelect");
+ inp.value = node;
+ inp.dispatchEvent(new Event("change", { bubbles: true }));
+ }, BENCH_NODE);
+ await page.waitForSelector(".matrix-table td.cell-filled", { timeout: 5000 });
+ const matrixMs = Date.now() - tMatrix;
+
+ const heat = await page.evaluate(() => {
+ const cells = Array.from(document.querySelectorAll(".matrix-table td.cell-filled"));
+ const colors = new Set(cells.map((c) => c.style.background || getComputedStyle(c).backgroundColor));
+ return { filled: cells.length, distinct: colors.size, sample: Array.from(colors).slice(0, 4) };
+ });
+ check(heat.filled > 0, "no filled matrix cells rendered");
+ check(heat.distinct > 1, `matrix is not a heatmap: only ${heat.distinct} distinct fill colour(s)`);
+ ok(`heatmap rendered in ${matrixMs}ms: ${heat.filled} filled cells, ${heat.distinct} distinct colours (e.g. ${JSON.stringify(heat.sample)})`);
+
+ // switch the metric and confirm it stays a heatmap (updateMatrixMetric path)
+ const hasSelect = await page.$(".matrix-metric-select");
+ if (hasSelect) {
+ const opts = await page.$$eval(".matrix-metric-select option", (os) => os.map((o) => o.value));
+ if (opts.length > 1) {
+ await page.selectOption(".matrix-metric-select", opts[1]);
+ await page.waitForTimeout(100);
+ const heat2 = await page.evaluate(() => {
+ const cells = Array.from(document.querySelectorAll(".matrix-table td.cell-filled"));
+ return new Set(cells.map((c) => c.style.background)).size;
+ });
+ check(heat2 > 1, `after metric switch the matrix lost its gradient (${heat2} colours)`);
+ ok(`metric switch keeps heatmap (${heat2} distinct colours on metric '${opts[1]}')`);
+ }
+ }
+
+ // ── 4) knowledge graph zoom + drag (Overview radial) ───────────────────
+ await setPhase("graph-zoom");
+ await page.click('[data-tab="overview"]');
+ await page.waitForSelector("#overviewGraph svg.dg-graph-svg", { timeout: 6000 }).catch(() => {});
+ const svg = await page.$("#overviewGraph svg.dg-graph-svg");
+ if (svg) {
+ const box = await svg.boundingBox();
+ const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
+ for (let i = 0; i < 5; i++) { await page.mouse.move(cx, cy); await page.mouse.wheel(0, -120); await page.waitForTimeout(40); }
+ await page.mouse.move(cx, cy); await page.mouse.down();
+ await page.mouse.move(cx + 60, cy + 40, { steps: 6 }); await page.mouse.up();
+ ok("knowledge-graph zoom + drag ran");
+ } else {
+ ok("overview graph not present (skipped zoom/drag)");
+ }
+
+ // ── 5) search ──────────────────────────────────────────────────────────
+ await setPhase("search");
+ await page.fill("#searchInput", "Method");
+ await page.waitForTimeout(500);
+ const searchOpen = await page.evaluate(() => document.getElementById("searchResults")?.classList.contains("open"));
+ check(!!searchOpen, "search dropdown did not open");
+ if (searchOpen) ok("search returned results");
+ await page.keyboard.press("Escape");
+
+ // ── 6) language switch ─────────────────────────────────────────────────
+ await setPhase("lang-switch");
+ const enLabel = await page.$eval('[data-i18n="nav.overview"]', (e) => e.textContent.trim());
+ await page.click('.lang-btn[data-lang="zh"]');
+ try {
+ await page.waitForFunction((en) => document.querySelector('[data-i18n="nav.overview"]')?.textContent.trim() !== en, enLabel, { timeout: 3000 });
+ ok("language switched to zh");
+ } catch { check(false, "language switch did not apply within 3s"); }
+
+ // ── 7) idle and watch for long tasks / interactivity ───────────────────
+ await setPhase("idle");
+ line(` idling ${IDLE_MS}ms, watching for >${LONGTASK_BUDGET_MS}ms long tasks…`);
+ let maxRaf = 0;
+ const hangStart = Date.now();
+ while (Date.now() - hangStart < IDLE_MS) {
+ const raf = await page.evaluate(() => new Promise((res) => {
+ const s = performance.now();
+ requestAnimationFrame(() => res(performance.now() - s));
+ }));
+ maxRaf = Math.max(maxRaf, raf);
+ await page.waitForTimeout(1000);
+ }
+
+ const longtasks = await page.evaluate(() => window.__longtasks || []);
+ const worst = longtasks.reduce((m, t) => Math.max(m, t.duration), 0);
+ const over = longtasks.filter((t) => t.duration > LONGTASK_BUDGET_MS);
+ line(` long tasks: ${longtasks.length} total, worst ${worst.toFixed(1)}ms, ${over.length} over ${LONGTASK_BUDGET_MS}ms, max rAF ${maxRaf.toFixed(0)}ms`);
+ for (const t of over) line(` >budget ${t.duration.toFixed(0)}ms phase=${t.phase}`);
+ check(over.length === 0, `${over.length} main-thread long task(s) > ${LONGTASK_BUDGET_MS}ms (worst ${worst.toFixed(1)}ms)`);
+
+ // still interactive after the idle
+ await page.click('[data-tab="explore"]');
+ const interactive = await page.evaluate(() => !!document.getElementById("tab-explore"));
+ check(interactive, "page not interactive after idle");
+
+ check(consoleErrors.length === 0, `${consoleErrors.length} console error(s): ${consoleErrors.slice(0, 5).join(" | ")}`);
+ check(pageErrors.length === 0, `${pageErrors.length} page error(s): ${pageErrors.slice(0, 5).join(" | ")}`);
+} catch (e) {
+ check(false, `exception: ${e && e.stack ? e.stack : e}`);
+} finally {
+ if (browser) await browser.close();
+ server.kill("SIGINT");
+ try { await once(server, "exit"); } catch { /* ignore */ }
+}
+
+if (fails.length) { line(`\nDASHBOARD E2E FAILED — ${fails.length} failure(s).`); process.exitCode = 1; }
+else { line("\nDASHBOARD E2E PASSED — fast, interactive, no long task > 200ms, real heatmap, no console errors."); }
diff --git a/tests/js/dashboard_render_perf.mjs b/tests/js/dashboard_render_perf.mjs
new file mode 100644
index 0000000..bd0aeaa
--- /dev/null
+++ b/tests/js/dashboard_render_perf.mjs
@@ -0,0 +1,134 @@
+// Perf microbenchmark (Acceptance A · node fallback) for the two render paths
+// this change adds/touches and that grow with data volume:
+//
+// 1. the Evidence benchmark HEATMAP colour scale
+// (web/static/js/app.js::buildMatrixHeat) — a single O(cells) min/max pass
+// plus an O(1) RGB lerp per cell.
+// 2. the CHUNKED list builder
+// (web/static/js/app.js::renderListChunked) — builds AND inserts one chunk
+// at a time, so the worst single synchronous slice is O(chunk), not O(n),
+// no matter how long the list gets.
+//
+// Runs inside REAL Chromium (the engine prod users run), mirroring the
+// production logic with a fast path and a deliberately O(n^2) "slow" reference,
+// and proves at the spec's N=5000:
+// • the fast path is < 200ms (the long-task threshold the browser E2E enforces)
+// • the O(n^2) reference is many times slower (so the harness would actually
+// catch a quadratic regression).
+import { chromium } from "playwright";
+
+const STRESS_N = 5000;
+const BUDGET_MS = 200;
+const CHUNK = 25;
+
+const browser = await chromium.launch({ args: ["--no-sandbox", "--disable-dev-shm-usage"] });
+let failed = false;
+const check = (cond, msg) => { if (!cond) { console.error(`FAIL: ${msg}`); failed = true; } };
+
+try {
+ const page = await browser.newPage();
+ await page.setContent("
");
+
+ const r = await page.evaluate(({ STRESS_N, CHUNK }) => {
+ const timeOnce = (fn) => { const t0 = performance.now(); const out = fn(); return { dt: performance.now() - t0, out }; };
+ const median = (fn, runs = 7) => {
+ const ts = []; let out;
+ for (let i = 0; i < runs; i++) { const r = timeOnce(fn); ts.push(r.dt); out = r.out; }
+ ts.sort((a, b) => a - b);
+ return { dt: ts[Math.floor(ts.length / 2)], out };
+ };
+
+ // ── 1) HEATMAP scale ────────────────────────────────────────────────
+ // A matrix with ~STRESS_N filled cells. cells keyed like app.js.
+ const side = Math.ceil(Math.sqrt(STRESS_N));
+ const cells = {};
+ for (let i = 0; i < side; i++) {
+ for (let j = 0; j < side; j++) {
+ cells[`m${i}|||d${j}|||acc`] = { value: (i * 31 + j * 17) % 100, is_sota: 0 };
+ }
+ }
+ const cellCount = Object.keys(cells).length;
+ const lo = [250, 241, 234], hi = [224, 168, 131];
+ const lerp = (a, b, t) => `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(a[2] + (b[2] - a[2]) * t)})`;
+
+ // fast == production buildMatrixHeat: ONE min/max pass, O(1) per cell.
+ function heatFast() {
+ let min = Infinity, max = -Infinity;
+ for (const k in cells) { const v = cells[k].value; if (v < min) min = v; if (v > max) max = v; }
+ const span = max - min, colors = new Set();
+ for (const k in cells) { const t = span > 0 ? (cells[k].value - min) / span : 0.5; colors.add(lerp(lo, hi, t)); }
+ return colors.size;
+ }
+ // slow == recompute min/max INSIDE the per-cell loop → O(n^2).
+ function heatSlow() {
+ const colors = new Set();
+ for (const k in cells) {
+ let min = Infinity, max = -Infinity;
+ for (const k2 in cells) { const v = cells[k2].value; if (v < min) min = v; if (v > max) max = v; }
+ const span = max - min, t = span > 0 ? (cells[k].value - min) / span : 0.5;
+ colors.add(lerp(lo, hi, t));
+ }
+ return colors.size;
+ }
+ const heatFastR = median(heatFast);
+ const heatSlowR = timeOnce(() => heatSlow()); // one run; O(n^2) is slow
+
+ // ── 2) CHUNKED list build ───────────────────────────────────────────
+ const items = Array.from({ length: STRESS_N }, (_, i) => ({ i, title: `Card ${i} & "q"` }));
+ const renderItem = (it) => ``;
+ const c = document.getElementById("c");
+
+ // fast == one synchronous slice of renderListChunked (build + insert CHUNK).
+ // Its cost must be ~constant regardless of items.length (proves O(chunk)).
+ function firstSlice() {
+ c.innerHTML = "";
+ let html = "";
+ const end = Math.min(CHUNK, items.length);
+ for (let i = 0; i < end; i++) html += renderItem(items[i]);
+ c.insertAdjacentHTML("beforeend", html);
+ return c.children.length;
+ }
+ // full O(n) build (map+join, single innerHTML) — what the old non-chunked
+ // renders did; still O(n) but ONE big task.
+ function fullBuild() {
+ c.innerHTML = items.map(renderItem).join("");
+ return c.children.length;
+ }
+ // slow == the O(n^2) trap: innerHTML += per item.
+ function quadBuild(n) {
+ c.innerHTML = "";
+ for (let i = 0; i < n; i++) c.innerHTML += renderItem(items[i]);
+ return c.children.length;
+ }
+ const sliceR = median(firstSlice);
+ const fullR = median(fullBuild);
+ const quadR = timeOnce(() => quadBuild(2000)); // 5000 the quadratic way is minutes
+
+ return { cellCount, heatFastR, heatSlowR, sliceR, fullR, quadR, side };
+ }, { STRESS_N, CHUNK });
+
+ console.log(`heat fast N=${r.cellCount}: ${r.heatFastR.dt.toFixed(2)}ms (${r.heatFastR.out} distinct colours, budget < ${BUDGET_MS}ms)`);
+ console.log(`heat slow O(n^2) N=${r.cellCount}: ${r.heatSlowR.dt.toFixed(2)}ms — reference, must be >> fast`);
+ console.log(`list slice N=${STRESS_N} chunk=${CHUNK}: ${r.sliceR.dt.toFixed(2)}ms (${r.sliceR.out} nodes) — worst single sync slice`);
+ console.log(`list full N=${STRESS_N}: ${r.fullR.dt.toFixed(2)}ms (${r.fullR.out} nodes, budget < ${BUDGET_MS}ms)`);
+ console.log(`list quad O(n^2) N=2000: ${r.quadR.dt.toFixed(2)}ms — reference, must be >> slice`);
+
+ // heatmap: fast under budget, produces a real gradient, and the harness is
+ // sensitive enough to catch a quadratic regression.
+ check(r.heatFastR.dt < BUDGET_MS, `heat fast ${r.heatFastR.dt.toFixed(2)}ms >= ${BUDGET_MS}ms`);
+ check(r.heatFastR.out > 1, `heat produced only ${r.heatFastR.out} colour(s) — not a gradient`);
+ check(r.heatSlowR.dt > r.heatFastR.dt * 5, `heat harness not sensitive (slow ${r.heatSlowR.dt.toFixed(2)}ms not >> fast)`);
+
+ // chunked list: the worst single sync slice is tiny and under budget even at
+ // N=5000; the full one-shot build stays O(n)/under budget; the O(n^2) trap is
+ // far slower (harness sensitivity).
+ check(r.sliceR.dt < BUDGET_MS, `list slice ${r.sliceR.dt.toFixed(2)}ms >= ${BUDGET_MS}ms`);
+ check(r.sliceR.out === CHUNK, `list slice produced ${r.sliceR.out} nodes, expected ${CHUNK}`);
+ check(r.fullR.dt < BUDGET_MS, `list full ${r.fullR.dt.toFixed(2)}ms >= ${BUDGET_MS}ms`);
+ check(r.quadR.dt > r.fullR.dt * 5, `list harness not sensitive (quad ${r.quadR.dt.toFixed(2)}ms not >> full)`);
+
+ if (!failed) console.log("PASS");
+} finally {
+ await browser.close();
+}
+if (failed) process.exitCode = 1;
diff --git a/tests/js/package.json b/tests/js/package.json
index 9821139..9fdb579 100644
--- a/tests/js/package.json
+++ b/tests/js/package.json
@@ -8,7 +8,11 @@
"test:adapter": "node graph_adapter.test.mjs",
"test:renderer": "node graph_renderer_perf.mjs",
"test:unit": "node graph_adapter.test.mjs && node graph_renderer_perf.mjs",
- "test:e2e": "node graph_e2e.mjs"
+ "test:e2e": "node graph_e2e.mjs",
+ "test:dashboard": "node dashboard_e2e.mjs",
+ "test:render-perf": "node dashboard_render_perf.mjs",
+ "test:responsiveness": "node responsiveness_e2e.mjs",
+ "test:dropdown-perf": "node taxonomy_dropdown_perf.mjs"
},
"dependencies": {
"d3": "^7.9.0",
diff --git a/tests/js/responsiveness_e2e.mjs b/tests/js/responsiveness_e2e.mjs
index eaac699..4c1ee3b 100644
--- a/tests/js/responsiveness_e2e.mjs
+++ b/tests/js/responsiveness_e2e.mjs
@@ -124,16 +124,26 @@ try {
check(statValues[id] && statValues[id] !== "0", `${id} not a real number: ${JSON.stringify(statValues[id])}`);
}
- // ── 1b) tooltips (Acceptance B): every stat card has a real, visible
- // hover explanation rendered into its title attribute. ─────────────
- const tooltips = await page.$$eval(".stat-card[data-i18n-title]", (cards) =>
- cards.map((c) => ({ key: c.getAttribute("data-i18n-title"), title: (c.getAttribute("title") || "").trim() }))
+ // ── 1b) tooltips (Acceptance B): every stat card carries a tooltip key and
+ // hovering one shows the custom tooltip (DGTooltip replaces the native
+ // `title` with a styled #tooltip element, so we assert that, not the
+ // removed native attribute). ─────────────────────────────────────────
+ const tipKeys = await page.$$eval(".stat-card[data-i18n-title]", (cards) =>
+ cards.map((c) => c.getAttribute("data-i18n-title"))
);
- check(tooltips.length === 9, `expected 9 stat-card tooltips, found ${tooltips.length}`);
- for (const t of tooltips) {
- check(t.title.length > 0, `stat card ${t.key} has no visible title tooltip`);
+ check(tipKeys.length === 9, `expected 9 stat-card tooltip keys, found ${tipKeys.length}`);
+ const firstCard = await page.$(".stat-card[data-i18n-title]");
+ if (firstCard) {
+ await firstCard.hover();
+ await page.waitForTimeout(150);
+ const tipVisible = await page.evaluate(() => {
+ const tip = document.getElementById("tooltip");
+ return !!tip && tip.classList.contains("visible") && tip.textContent.trim().length > 0;
+ });
+ check(tipVisible, "custom tooltip did not show on hovering a stat card");
+ await page.mouse.move(5, 5);
}
- report(`stat-card tooltips rendered: ${tooltips.length}/9 (e.g. ${JSON.stringify(tooltips[0])})`);
+ report(`stat-card tooltips: ${tipKeys.length}/9 keys present, custom hover tooltip shown`);
// ── 2a) idle (lets the idle prefetch build the 3300-node dropdown) ─────
await setPhase("idle-prefetch");
diff --git a/tests/js/seed_and_serve.py b/tests/js/seed_and_serve.py
index 520d70c..cf1f82e 100644
--- a/tests/js/seed_and_serve.py
+++ b/tests/js/seed_and_serve.py
@@ -37,6 +37,7 @@
database.init_db()
TAXONOMY_NODES = 3300
+BENCH_NODE = "ml.bench" # deterministic leaf with a real method x dataset matrix
PAPERS_PROCESSED = 1500 # status in extracted/abstracted/reasoned
PAPERS_UNPROCESSED = 700 # status ingested -> total 2200, processed 1500
RESULTS = 800
@@ -64,12 +65,15 @@ def seed():
from collections import deque
db.execute("INSERT INTO taxonomy_nodes (id, name, parent_id, depth, sort_order) VALUES (?,?,?,?,?)",
("ml", "Machine Learning", None, 0, 0))
+ # Reserve one slot for a deterministic *leaf* benchmark node ("ml.bench")
+ # so the Evidence E2E can navigate to a node that actually has a
+ # method x dataset matrix to render (and exercise the heatmap).
created, counter, fanout = 1, 0, 12
q = deque([("ml", 0)])
- while created < TAXONOMY_NODES and q:
+ while created < TAXONOMY_NODES - 1 and q:
parent, depth = q.popleft()
for k in range(fanout):
- if created >= TAXONOMY_NODES:
+ if created >= TAXONOMY_NODES - 1:
break
nid = f"{parent}.n{k}"
db.execute("INSERT INTO taxonomy_nodes (id, name, parent_id, depth, sort_order) VALUES (?,?,?,?,?)",
@@ -77,6 +81,8 @@ def seed():
q.append((nid, depth + 1))
created += 1
counter += 1
+ db.execute("INSERT INTO taxonomy_nodes (id, name, parent_id, depth, sort_order) VALUES (?,?,?,?,?)",
+ (BENCH_NODE, "Benchmark Leaf", "ml", 1, 99))
# Papers: processed (counted by the 文献 card) + unprocessed (NOT counted).
for i in range(PAPERS_PROCESSED):
@@ -87,12 +93,42 @@ def seed():
db.execute("INSERT INTO papers (id, title, status, token_cost) VALUES (?,?,?,?)",
(f"raw-{i}", f"Ingested-only paper {i}", "ingested", 0))
- # Results (基准结果).
- for i in range(RESULTS):
+ # Results (基准结果). Lay them out as a dense method x dataset grid on the
+ # BENCH_NODE leaf, with *varied* metric values across two metrics so the
+ # Evidence matrix renders a real heatmap (cell shade follows the value).
+ # Every result is linked to BENCH_NODE via result_taxonomy (the table the
+ # matrix builder actually joins on).
+ METHODS, DATASETS, METRICS = 30, 14, ("accuracy", "f1")
+ rid = 0
+ for mi in range(METHODS):
+ for di in range(DATASETS):
+ for metric in METRICS:
+ if rid >= RESULTS:
+ break
+ # Spread values widely (40–99) and deterministically so the
+ # heatmap has many distinct shades, not one flat fill.
+ val = 40 + ((mi * 7 + di * 13 + (0 if metric == "accuracy" else 5)) % 60)
+ is_sota = 1 if (mi == di % METHODS and metric == "accuracy") else 0
+ db.execute(
+ "INSERT INTO results (paper_id, node_id, method_name, dataset_name, "
+ "metric_name, metric_value, is_sota) VALUES (?,?,?,?,?,?,?)",
+ (f"proc-{rid % PAPERS_PROCESSED}", BENCH_NODE,
+ f"Method {mi:02d}", f"Dataset {di:02d}", metric, float(val), is_sota))
+ db.execute("INSERT INTO result_taxonomy (result_id, node_id) VALUES (?,?)",
+ (rid + 1, BENCH_NODE))
+ rid += 1
+ if rid >= RESULTS:
+ break
+ if rid >= RESULTS:
+ break
+ # Top up to the exact RESULTS count (keeps the overview metric stable) with
+ # a few extra rows that are not part of the displayed grid.
+ while rid < RESULTS:
db.execute(
"INSERT INTO results (paper_id, node_id, method_name, dataset_name, metric_name, metric_value) "
"VALUES (?,?,?,?,?,?)",
- (f"proc-{i % PAPERS_PROCESSED}", "ml", f"method{i}", f"dataset{i % 50}", "accuracy", 0.9))
+ (f"proc-{rid % PAPERS_PROCESSED}", "ml", f"extra_method{rid}", "extra_ds", "accuracy", 0.9))
+ rid += 1
# Claims + contradictions (矛盾).
for i in range(CONTRADICTIONS * 2):
@@ -102,16 +138,55 @@ def seed():
db.execute("INSERT INTO contradictions (claim_a_id, claim_b_id, description) VALUES (?,?,?)",
(2 * i + 1, 2 * i + 2, f"conflict {i}"))
- # Insights (研究洞见 -> insights table).
+ # Insights (研究洞见 -> insights table). Heavy, realistic rows so the
+ # Insights tab renders production-weight cards (long hypotheses/experiments
+ # plus a JSON-encoded supporting_papers list the frontend parses per card).
+ _LOREM = ("We hypothesize that the observed gains stem from a previously "
+ "unmodeled interaction between the gating mechanism and the "
+ "normalization schedule, which compounds across depth. ") * 4
+ _types = ("contradiction_analysis", "method_transfer", "assumption_challenge",
+ "ignored_limitation", "paradigm_exhaustion", "cross_domain_bridge")
for i in range(INSIGHTS):
+ papers = json.dumps([f"2401.{1000 + ((i * 7 + k) % 9000):05d}" for k in range(8)])
db.execute(
- "INSERT INTO insights (node_id, insight_type, title, hypothesis) VALUES (?,?,?,?)",
- ("ml", "cross_domain_bridge", f"Insight {i}", f"hypothesis {i}"))
+ "INSERT INTO insights (node_id, insight_type, title, hypothesis, evidence, "
+ "experiment, impact, novelty_score, feasibility_score, supporting_papers) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?)",
+ ("ml", _types[i % len(_types)], f"Research Insight {i}: {_LOREM[:60]}",
+ _LOREM, _LOREM, _LOREM, _LOREM, 1 + (i % 5), 1 + ((i + 2) % 5), papers))
- # Deep insights (深度发现 -> deep_insights table).
+ # Deep insights (深度发现 -> deep_insights table). Heavy + *displayable*
+ # (problem_statement / proposed_method / experimental_plan present) so the
+ # Discoveries tab renders real, production-weight cards. Each row carries
+ # several JSON fields the frontend parses per card — this is the ~746KB
+ # /api/deep_insights?limit=50 payload that drove the main-thread long tasks.
+ method_json = json.dumps({
+ "name": "Adaptive Gated Mixture", "type": "architecture",
+ "one_line": _LOREM[:120],
+ "definition": _LOREM * 2,
+ })
+ plan_json = json.dumps({
+ "baselines": [{"name": f"Baseline {b}"} for b in range(6)],
+ "datasets": [{"name": f"Dataset {d:02d}"} for d in range(8)],
+ "compute_budget": {"total_gpu_hours": 512},
+ "ablations": [{"name": f"ablation_{a}", "detail": _LOREM[:200]} for a in range(5)],
+ })
+ preds_json = json.dumps([{"statement": _LOREM[:140]} for _ in range(4)])
+ crit_json = json.dumps({"strongest_attack": _LOREM, "rebuttals": [_LOREM] * 3})
+ fielda_json = json.dumps({"node_id": "ml", "name": "Source Field"})
+ fieldb_json = json.dumps({"node_id": BENCH_NODE, "name": "Target Field"})
for i in range(DEEP_INSIGHTS):
- db.execute("INSERT INTO deep_insights (tier, status, title) VALUES (?,?,?)",
- (1 + (i % 2), "discovered", f"Discovery {i}"))
+ tier = 1 + (i % 2)
+ db.execute(
+ "INSERT INTO deep_insights (tier, status, title, novelty_status, "
+ "adversarial_score, formal_structure, transformation, field_a, field_b, "
+ "predictions, adversarial_critique, problem_statement, existing_weakness, "
+ "proposed_method, experimental_plan, evidence_summary) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
+ (tier, "discovered", f"Discovery {i}: {_LOREM[:50]}",
+ ("novel", "partially_exists", "exists")[i % 3], 6.0 + (i % 4),
+ _LOREM, _LOREM, fielda_json, fieldb_json, preds_json, crit_json,
+ _LOREM, _LOREM, method_json, plan_json, _LOREM * 2))
# Experiment runs (实验运行).
for i in range(EXPERIMENT_RUNS):
diff --git a/tests/test_web_app.py b/tests/test_web_app.py
index ef8c8e8..6039067 100644
--- a/tests/test_web_app.py
+++ b/tests/test_web_app.py
@@ -305,6 +305,98 @@ def test_dashboard_dead_code_is_removed(self):
self.assertEqual(app_py.count("def _planned_tracks("), 1)
self.assertNotIn('label": "主实验"', app_py)
self.assertNotIn("function renderExperimentGroups(groups)", app_js)
+ # The unreachable "opportunities" rendering path (no DOM target exists;
+ # loadOpportunities was never called) is removed, not just orphaned.
+ for dead in (
+ "function loadOpportunities",
+ "function renderOpportunities",
+ "const insightTypeColors",
+ "function insightTypeLabel",
+ "allOpportunities",
+ "oppsLoaded",
+ ):
+ self.assertNotIn(dead, app_js, f"dead opportunities symbol still present: {dead}")
+
+ def test_evidence_matrix_is_a_heatmap(self):
+ """Acceptance B — the benchmark matrix shades filled cells by value.
+
+ The old matrix gave every filled cell one flat class (`.cell-filled`,
+ a single background). A heatmap must compute a per-cell background from
+ the cell's value via a colour scale. We pin to source: a value→colour
+ builder exists, the cell markup carries an inline `background:` derived
+ from it, and the scale endpoints live in :root as CSS variables. The
+ "more than one distinct fill colour" wall-clock proof lives in the
+ Playwright gate (dashboard_e2e.mjs) and the perf microbench.
+ """
+ app_js = _read("web/static/js/app.js")
+ css = _read("web/static/css/style.css")
+ self.assertIn("function buildMatrixHeat(", app_js)
+ body = re.search(
+ r"function renderMatrix\(container, matrix\)\s*\{(?P.*?)\n\}",
+ app_js,
+ re.S,
+ )
+ self.assertIsNotNone(body, "renderMatrix not found")
+ fn = body.group("body")
+ # A filled cell's background is computed from the heat scale, inline.
+ self.assertRegex(fn, r"heat\(", "renderMatrix must colour cells via the heat scale")
+ self.assertRegex(fn, r"background:\$\{", "filled cells must carry an inline heat background")
+ # Switching the metric must recolour, not just relabel.
+ update = re.search(
+ r"function updateMatrixMetric\(selectEl\)\s*\{(?P.*?)\n\}",
+ app_js,
+ re.S,
+ )
+ self.assertIsNotNone(update, "updateMatrixMetric not found")
+ self.assertRegex(update.group("body"), r"buildMatrixHeat\(|heat\(",
+ "updateMatrixMetric must recompute the heat colours")
+ # Scale endpoints are themeable CSS variables.
+ self.assertIn("--heat-lo:", css)
+ self.assertIn("--heat-hi:", css)
+
+ def test_style_scales_exist_and_are_applied(self):
+ """Acceptance B — :root carries spacing / type / shadow scales and the
+ scattered hard-coded values are pulled into them (not left inline)."""
+ css = _read("web/static/css/style.css")
+ # Scales are defined.
+ for token in ("--space-8:", "--space-12:", "--space-16:",
+ "--text-sm:", "--text-base:", "--text-lg:",
+ "--shadow-sm:", "--shadow-lg:", "--shadow-focus:"):
+ self.assertIn(token, css, f"missing scale token {token}")
+ # Scales are actually applied (not just declared).
+ self.assertGreaterEqual(css.count("var(--space-"), 50)
+ self.assertGreaterEqual(css.count("var(--text-"), 20)
+ self.assertGreaterEqual(css.count("var(--shadow-"), 4)
+ # The hard-coded elevation shadows are collected into tokens: no raw
+ # `box-shadow: 0 px ...` literals remain outside the :root scale.
+ root = re.search(r":root\s*\{.*?\n\}", css, re.S)
+ css_outside_root = css.replace(root.group(0), "") if root else css
+ self.assertIsNone(
+ re.search(r"box-shadow:\s*0\s+\d+px", css_outside_root),
+ "a hard-coded box-shadow literal still lives outside the shadow scale",
+ )
+
+ def test_heavy_list_renders_are_chunked_not_one_shot(self):
+ """Acceptance A — lists that grow with data are built AND inserted in
+ chunks, so no single synchronous task scales with the list length.
+
+ The old helper chunked only the DOM insertion; the card HTML (parsing
+ several JSON fields per row on ~0.6–0.7 MB payloads) was still built in
+ one synchronous `items.map(...)`. The new renderListChunked takes
+ (container, items, renderItem) and builds each chunk lazily. We pin to
+ source; the wall-clock proof is the perf microbench + the E2E.
+ """
+ app_js = _read("web/static/js/app.js")
+ self.assertIn("function renderListChunked(container, items, renderItem", app_js)
+ # No O(n^2) `innerHTML +=` anywhere in the frontend bundle.
+ self.assertIsNone(re.search(r"\.innerHTML\s*\+=", app_js),
+ "`innerHTML +=` (O(n^2)) must not appear in app.js")
+ # The heavy tabs render through the chunked builder, not one-shot
+ # `list.innerHTML = X.map(...).join('')`.
+ for fn_name in ("loadDiscoveriesTab", "loadInsightsTab",
+ "renderExperiments", "renderAutoResearchJobs"):
+ self.assertIn(fn_name, app_js)
+ self.assertGreaterEqual(app_js.count("renderListChunked(list"), 6)
def test_taxonomy_dropdown_build_is_not_quadratic(self):
"""Regression guard for the main-thread freeze (Acceptance A).
diff --git a/web/static/css/style.css b/web/static/css/style.css
index d5287c8..e9b8376 100644
--- a/web/static/css/style.css
+++ b/web/static/css/style.css
@@ -36,6 +36,42 @@
--radius-sm: 5px;
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+ /* ── Spacing scale (px-named so values are unambiguous) ───────────── */
+ --space-2: 2px;
+ --space-4: 4px;
+ --space-6: 6px;
+ --space-8: 8px;
+ --space-10: 10px;
+ --space-12: 12px;
+ --space-14: 14px;
+ --space-16: 16px;
+ --space-18: 18px;
+ --space-20: 20px;
+ --space-24: 24px;
+
+ /* ── Type scale ───────────────────────────────────────────────────── */
+ --text-2xs: 0.62rem;
+ --text-xs: 0.68rem;
+ --text-sm: 0.72rem;
+ --text-md: 0.75rem;
+ --text-base: 0.78rem;
+ --text-lg: 0.82rem;
+ --text-xl: 0.92rem;
+ --text-2xl: 1rem;
+ --text-3xl: 1.1rem;
+
+ /* ── Elevation / shadow scale ─────────────────────────────────────── */
+ --shadow-sm: 0 4px 16px rgba(0,0,0,0.05);
+ --shadow-md: 0 4px 16px rgba(0,0,0,0.06);
+ --shadow-lg: 0 8px 24px rgba(0,0,0,0.08);
+ --shadow-xl: 0 12px 32px rgba(0,0,0,0.12);
+ --shadow-2xl: 0 12px 40px rgba(0,0,0,0.1);
+ --shadow-focus: 0 0 0 3px var(--accent-dim);
+
+ /* ── Benchmark-matrix heatmap scale (Evidence tab) ────────────────── */
+ --heat-lo: #faf1ea; /* lowest value — pale warm tint */
+ --heat-hi: #e0a883; /* highest value — saturated terracotta */
+
/* ── Knowledge-graph encoding tokens (issue #19) ──────────────── */
--graph-gap-lo: #eef3ee; /* node fill, no/low open gaps */
--graph-gap-hi: #3d8b5e; /* node fill, many open gaps (salient) */
@@ -84,15 +120,15 @@ header#topBar {
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
- padding: 0 20px;
- gap: 16px;
+ padding: 0 var(--space-20);
+ gap: var(--space-16);
z-index: 100;
}
.topbar-left {
display: flex;
align-items: center;
- gap: 12px;
+ gap: var(--space-12);
flex-shrink: 0;
}
@@ -116,8 +152,8 @@ header#topBar {
}
.live-badge {
- font-size: 0.62rem;
- padding: 3px 10px;
+ font-size: var(--text-2xs);
+ padding: 3px var(--space-10);
border-radius: 10px;
background: var(--bg-elevated);
color: var(--text-dim);
@@ -137,8 +173,8 @@ header#topBar {
.topbar-stats {
display: flex;
- gap: 20px;
- margin-left: 12px;
+ gap: var(--space-20);
+ margin-left: var(--space-12);
flex-shrink: 0;
}
.topbar-stat {
@@ -167,7 +203,7 @@ header#topBar {
min-width: 0;
display: flex;
align-items: center;
- gap: 10px;
+ gap: var(--space-10);
}
/* ── Search ───────────────────────────────────────────────────────── */
@@ -177,8 +213,8 @@ header#topBar {
.language-toggle {
display: inline-flex;
align-items: center;
- gap: 2px;
- padding: 2px;
+ gap: var(--space-2);
+ padding: var(--space-2);
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-base);
@@ -210,12 +246,12 @@ header#topBar {
.search-input {
width: 100%;
- padding: 8px 14px 8px 36px;
+ padding: var(--space-8) var(--space-14) var(--space-8) 36px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-base);
color: var(--text-primary);
- font-size: 0.82rem;
+ font-size: var(--text-lg);
font-family: var(--font);
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
@@ -223,7 +259,7 @@ header#topBar {
.search-input::placeholder { color: var(--text-muted); }
.search-input:focus {
border-color: var(--accent);
- box-shadow: 0 0 0 3px var(--accent-dim);
+ box-shadow: var(--shadow-focus);
}
.search-results {
@@ -236,7 +272,7 @@ header#topBar {
max-height: 460px;
overflow-y: auto;
z-index: 9999;
- box-shadow: 0 12px 40px rgba(0,0,0,0.1);
+ box-shadow: var(--shadow-2xl);
display: none;
}
.search-results.open { display: block; }
@@ -245,14 +281,14 @@ header#topBar {
.search-section:last-child { border-bottom: none; }
.search-section-title {
color: var(--accent);
- font-size: 0.62rem;
+ font-size: var(--text-2xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
- margin-bottom: 6px;
+ margin-bottom: var(--space-6);
}
.search-result-item {
- padding: 7px 10px;
+ padding: 7px var(--space-10);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
@@ -290,22 +326,22 @@ header#topBar {
.nav-items {
flex: 1;
- padding: 12px 8px;
+ padding: var(--space-12) var(--space-8);
display: flex;
flex-direction: column;
- gap: 2px;
+ gap: var(--space-2);
}
.nav-item {
display: flex;
align-items: center;
- gap: 12px;
- padding: 10px 14px;
+ gap: var(--space-12);
+ padding: var(--space-10) var(--space-14);
border: none;
background: none;
color: var(--text-secondary);
font-family: var(--font);
- font-size: 0.82rem;
+ font-size: var(--text-lg);
font-weight: 500;
border-radius: 8px;
cursor: pointer;
@@ -329,16 +365,16 @@ header#topBar {
font-weight: 600;
}
.advanced-nav {
- margin-top: 8px;
- padding-top: 8px;
+ margin-top: var(--space-8);
+ padding-top: var(--space-8);
border-top: 1px solid var(--border);
}
.advanced-nav summary {
list-style: none;
cursor: pointer;
- padding: 8px 14px;
+ padding: var(--space-8) var(--space-14);
color: var(--text-dim);
- font-size: 0.72rem;
+ font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -352,13 +388,13 @@ header#topBar {
.advanced-nav-item {
display: block;
width: 100%;
- margin: 2px 0;
- padding: 8px 14px 8px 26px;
+ margin: var(--space-2) 0;
+ padding: var(--space-8) var(--space-14) var(--space-8) 26px;
border: none;
background: none;
color: var(--text-secondary);
font-family: var(--font);
- font-size: 0.78rem;
+ font-size: var(--text-base);
font-weight: 500;
text-align: left;
border-radius: 8px;
@@ -375,24 +411,24 @@ header#topBar {
/* Sidebar footer */
.sidebar-footer {
- padding: 12px 8px;
+ padding: var(--space-12) var(--space-8);
border-top: 1px solid var(--border);
}
.pipeline-controls {
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: var(--space-4);
}
.btn-pipeline {
display: flex;
align-items: center;
- gap: 8px;
- padding: 8px 12px;
+ gap: var(--space-8);
+ padding: var(--space-8) var(--space-12);
border: 1px solid var(--border);
background: none;
color: var(--text-secondary);
font-family: var(--font);
- font-size: 0.75rem;
+ font-size: var(--text-md);
font-weight: 600;
border-radius: var(--radius-sm);
cursor: pointer;
@@ -448,10 +484,10 @@ header#topBar {
.tab-scroll {
height: 100%;
overflow-y: auto;
- padding: 20px 24px 32px;
+ padding: var(--space-20) var(--space-24) 32px;
display: flex;
flex-direction: column;
- gap: 16px;
+ gap: var(--space-16);
}
/* ── Cards ────────────────────────────────────────────────────────── */
@@ -460,7 +496,7 @@ header#topBar {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
- padding: 20px;
+ padding: var(--space-20);
transition: border-color var(--transition);
}
.card:hover { border-color: var(--border-hover); }
@@ -478,32 +514,32 @@ header#topBar {
font-weight: 700;
}
.advanced-panel summary {
- padding: 12px 16px;
- font-size: 0.82rem;
+ padding: var(--space-12) var(--space-16);
+ font-size: var(--text-lg);
}
.advanced-panel .card {
- margin: 0 12px 12px;
+ margin: 0 var(--space-12) var(--space-12);
}
.advanced-inline {
- margin-top: 8px;
- padding: 8px 10px;
+ margin-top: var(--space-8);
+ padding: var(--space-8) var(--space-10);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-elevated);
}
.advanced-inline summary {
- font-size: 0.75rem;
+ font-size: var(--text-md);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
- margin-bottom: 14px;
- gap: 12px;
+ margin-bottom: var(--space-14);
+ gap: var(--space-12);
}
.card-header h3 {
- font-size: 0.92rem;
+ font-size: var(--text-xl);
font-weight: 600;
font-family: var(--font-serif);
color: var(--text-primary);
@@ -515,7 +551,7 @@ header#topBar {
border: none;
color: var(--accent);
font-family: var(--font);
- font-size: 0.75rem;
+ font-size: var(--text-md);
font-weight: 600;
cursor: pointer;
transition: opacity var(--transition);
@@ -525,9 +561,9 @@ header#topBar {
/* ── Badges ───────────────────────────────────────────────────────── */
.badge {
- font-size: 0.62rem;
+ font-size: var(--text-2xs);
font-weight: 700;
- padding: 3px 8px;
+ padding: 3px var(--space-8);
border-radius: 8px;
letter-spacing: 0.04em;
}
@@ -542,18 +578,18 @@ header#topBar {
.stat-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
- gap: 12px;
+ gap: var(--space-12);
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
- padding: 18px 16px;
+ padding: var(--space-18) var(--space-16);
display: flex;
flex-direction: column;
align-items: center;
- gap: 6px;
+ gap: var(--space-6);
transition: border-color var(--transition), transform 0.15s ease;
}
.stat-card:hover {
@@ -565,7 +601,7 @@ header#topBar {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
border-radius: 10px;
- margin-bottom: 4px;
+ margin-bottom: var(--space-4);
}
.stat-icon-blue { background: var(--accent-dim); color: var(--accent); }
.stat-icon-cyan { background: var(--accent-dim); color: var(--accent); }
@@ -584,7 +620,7 @@ header#topBar {
line-height: 1;
}
.stat-label {
- font-size: 0.68rem;
+ font-size: var(--text-xs);
color: var(--text-dim);
font-weight: 600;
text-transform: uppercase;
@@ -596,24 +632,24 @@ header#topBar {
.processing-list {
display: flex;
flex-wrap: wrap;
- gap: 8px;
+ gap: var(--space-8);
}
.proc-item {
display: inline-flex;
align-items: center;
- gap: 8px;
+ gap: var(--space-8);
background: var(--accent-dim);
border: 1px solid rgba(196, 112, 75, 0.15);
border-radius: 8px;
- padding: 7px 12px;
- font-size: 0.72rem;
+ padding: 7px var(--space-12);
+ font-size: var(--text-sm);
animation: proc-pulse 2.5s ease-in-out infinite;
}
.proc-item .proc-id {
color: var(--accent);
font-family: var(--font-mono);
font-weight: 700;
- font-size: 0.68rem;
+ font-size: var(--text-xs);
}
.proc-item .proc-title {
color: var(--text-secondary);
@@ -625,7 +661,7 @@ header#topBar {
}
.proc-item .proc-step {
color: var(--green);
- font-size: 0.68rem;
+ font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.02em;
}
@@ -647,11 +683,11 @@ header#topBar {
.recently-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
- gap: 10px;
+ gap: var(--space-10);
}
.recently-item {
- padding: 14px 16px;
+ padding: var(--space-14) var(--space-16);
border-radius: var(--radius);
border-left: 3px solid var(--border);
background: var(--bg-card);
@@ -660,7 +696,7 @@ header#topBar {
}
.recently-item:hover {
transform: translateY(-1px);
- box-shadow: 0 4px 16px rgba(0,0,0,0.06);
+ box-shadow: var(--shadow-md);
background: var(--bg-card-hover);
}
.recently-item.type-gap { border-left-color: var(--green); }
@@ -695,7 +731,7 @@ header#topBar {
font-weight: 600;
font-size: 0.8rem;
line-height: 1.4;
- margin-bottom: 4px;
+ margin-bottom: var(--space-4);
}
.recently-item .ri-desc {
color: var(--text-secondary);
@@ -709,7 +745,7 @@ header#topBar {
.recently-item .ri-meta {
color: var(--text-dim);
font-size: 0.65rem;
- margin-top: 6px;
+ margin-top: var(--space-6);
}
/* ── Graph / Explore ──────────────────────────────────────────────── */
@@ -725,17 +761,17 @@ header#topBar {
display: flex;
align-items: center;
flex-wrap: wrap;
- gap: 4px;
- padding: 10px 16px;
+ gap: var(--space-4);
+ padding: var(--space-10) var(--space-16);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
- font-size: 0.82rem;
+ font-size: var(--text-lg);
}
.crumb {
color: var(--accent);
cursor: pointer;
- padding: 3px 8px;
+ padding: 3px var(--space-8);
border-radius: 4px;
transition: background var(--transition);
font-weight: 500;
@@ -751,51 +787,51 @@ header#topBar {
background: linear-gradient(135deg, rgba(196,112,75,0.04) 0%, transparent 50%);
border: 1px solid rgba(196,112,75,0.12);
border-radius: var(--radius);
- padding: 18px 20px;
- margin-bottom: 14px;
+ padding: var(--space-18) var(--space-20);
+ margin-bottom: var(--space-14);
}
.summary-hero h4 {
- font-size: 1.1rem;
+ font-size: var(--text-3xl);
font-family: var(--font-serif);
color: var(--text-primary);
- margin-bottom: 8px;
+ margin-bottom: var(--space-8);
font-weight: 600;
}
.summary-hero p {
color: var(--text-secondary);
font-size: 0.84rem;
line-height: 1.7;
- margin-bottom: 8px;
+ margin-bottom: var(--space-8);
}
.summary-hero p:last-child { margin-bottom: 0; }
.summary-grid {
display: grid;
grid-template-columns: 1fr 1fr;
- gap: 12px;
- margin-top: 12px;
+ gap: var(--space-12);
+ margin-top: var(--space-12);
}
.summary-card-inner {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius);
- padding: 14px;
+ padding: var(--space-14);
}
.summary-card-inner h4 {
color: var(--accent);
- font-size: 0.72rem;
+ font-size: var(--text-sm);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
- margin-bottom: 10px;
+ margin-bottom: var(--space-10);
}
.summary-item {
- padding: 10px 12px;
+ padding: var(--space-10) var(--space-12);
border-radius: 8px;
border: 1px solid var(--border);
- margin-bottom: 8px;
+ margin-bottom: var(--space-8);
background: var(--bg-card);
transition: border-color var(--transition);
}
@@ -805,34 +841,34 @@ header#topBar {
display: block;
color: var(--text-primary);
font-size: 0.8rem;
- margin-bottom: 4px;
+ margin-bottom: var(--space-4);
}
.summary-item p {
color: var(--text-secondary);
- font-size: 0.78rem;
+ font-size: var(--text-base);
line-height: 1.6;
}
.summary-item .meta {
color: var(--text-dim);
- font-size: 0.68rem;
+ font-size: var(--text-xs);
margin-top: 5px;
}
.chip-row {
display: flex;
flex-wrap: wrap;
- gap: 6px;
- margin-top: 6px;
+ gap: var(--space-6);
+ margin-top: var(--space-6);
}
.chip {
display: inline-flex;
align-items: center;
- padding: 4px 10px;
+ padding: var(--space-4) var(--space-10);
border-radius: 999px;
background: var(--accent-dim);
border: 1px solid rgba(196, 112, 75, 0.12);
color: var(--text-secondary);
- font-size: 0.72rem;
+ font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: background var(--transition);
@@ -843,10 +879,10 @@ header#topBar {
.children-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
- gap: 10px;
+ gap: var(--space-10);
}
.child-card {
- padding: 12px 14px;
+ padding: var(--space-12) var(--space-14);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
@@ -859,13 +895,13 @@ header#topBar {
transform: translateY(-1px);
}
.child-card .child-name {
- font-size: 0.82rem;
+ font-size: var(--text-lg);
font-weight: 600;
color: var(--text-primary);
- margin-bottom: 4px;
+ margin-bottom: var(--space-4);
}
.child-card .child-stats {
- font-size: 0.68rem;
+ font-size: var(--text-xs);
color: var(--text-dim);
}
.child-card .child-stats span { margin-right: 8px; }
@@ -875,18 +911,18 @@ header#topBar {
.evidence-controls {
display: flex;
align-items: center;
- gap: 12px;
- margin-bottom: 16px;
+ gap: var(--space-12);
+ margin-bottom: var(--space-16);
flex-wrap: wrap;
}
.evidence-controls label {
- font-size: 0.78rem;
+ font-size: var(--text-base);
color: var(--text-dim);
font-weight: 600;
}
.evidence-hint {
color: var(--text-dim);
- font-size: 0.75rem;
+ font-size: var(--text-md);
margin-left: auto;
}
@@ -896,7 +932,7 @@ header#topBar {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: var(--font);
- font-size: 0.78rem;
+ font-size: var(--text-base);
outline: none;
transition: border-color var(--transition);
}
@@ -909,9 +945,9 @@ header#topBar {
.matrix-controls {
display: flex;
align-items: center;
- gap: 12px;
- margin-bottom: 12px;
- font-size: 0.78rem;
+ gap: var(--space-12);
+ margin-bottom: var(--space-12);
+ font-size: var(--text-base);
}
.matrix-controls label { color: var(--text-dim); font-weight: 600; }
.matrix-controls select {
@@ -919,8 +955,8 @@ header#topBar {
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
- padding: 5px 10px;
- font-size: 0.78rem;
+ padding: 5px var(--space-10);
+ font-size: var(--text-base);
font-family: var(--font);
}
.matrix-info { color: var(--text-dim); margin-left: auto; font-size: 0.72rem; font-weight: 500; }
@@ -929,13 +965,13 @@ header#topBar {
.matrix-table {
border-collapse: collapse;
- font-size: 0.72rem;
+ font-size: var(--text-sm);
width: auto;
min-width: 100%;
}
.matrix-table th, .matrix-table td {
border: 1px solid var(--border);
- padding: 7px 10px;
+ padding: 7px var(--space-10);
text-align: center;
white-space: nowrap;
}
@@ -974,11 +1010,11 @@ header#topBar {
.gaps-list { display: flex; flex-direction: column; gap: 8px; }
.gap-item {
- padding: 12px 14px;
+ padding: var(--space-12) var(--space-14);
border-radius: var(--radius);
background: var(--green-dim);
border-left: 3px solid var(--green);
- font-size: 0.78rem;
+ font-size: var(--text-base);
line-height: 1.6;
}
.gap-item .score {
@@ -995,17 +1031,17 @@ header#topBar {
.papers-controls {
display: flex;
- gap: 10px;
+ gap: var(--space-10);
align-items: center;
}
.search-input-sm {
- padding: 6px 12px;
+ padding: var(--space-6) var(--space-12);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text-primary);
- font-size: 0.78rem;
+ font-size: var(--text-base);
font-family: var(--font);
outline: none;
width: 220px;
@@ -1017,13 +1053,13 @@ header#topBar {
.papers-list {
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: var(--space-4);
max-height: calc(100vh - 260px);
overflow-y: auto;
}
.paper-mini-stats {
- margin-bottom: 14px;
+ margin-bottom: var(--space-14);
}
.stat-card-mini {
@@ -1033,14 +1069,14 @@ header#topBar {
.paper-flow-list {
display: flex;
flex-direction: column;
- gap: 10px;
+ gap: var(--space-10);
}
.paper-flow-item {
border: 1px solid var(--border);
background: var(--bg-card);
border-radius: var(--radius);
- padding: 14px 16px;
+ padding: var(--space-14) var(--space-16);
transition: border-color var(--transition), background var(--transition), transform var(--transition);
}
@@ -1054,8 +1090,8 @@ header#topBar {
display: flex;
justify-content: space-between;
align-items: flex-start;
- gap: 12px;
- margin-bottom: 8px;
+ gap: var(--space-12);
+ margin-bottom: var(--space-8);
}
.paper-flow-title {
@@ -1068,17 +1104,17 @@ header#topBar {
.paper-flow-meta {
display: flex;
flex-wrap: wrap;
- gap: 12px;
+ gap: var(--space-12);
color: var(--text-dim);
- font-size: 0.72rem;
- margin-bottom: 8px;
+ font-size: var(--text-sm);
+ margin-bottom: var(--space-8);
}
.paper-flow-note {
color: var(--text-secondary);
- font-size: 0.78rem;
+ font-size: var(--text-base);
line-height: 1.6;
- margin-top: 6px;
+ margin-top: var(--space-6);
}
.paper-flow-error {
@@ -1088,12 +1124,12 @@ header#topBar {
.paper-flow-actions {
display: flex;
flex-wrap: wrap;
- gap: 8px;
- margin-top: 12px;
+ gap: var(--space-8);
+ margin-top: var(--space-12);
}
.paper-row {
- padding: 12px 14px;
+ padding: var(--space-12) var(--space-14);
border-radius: 8px;
border-left: 3px solid var(--border);
transition: background var(--transition), border-color var(--transition);
@@ -1111,7 +1147,7 @@ header#topBar {
.paper-row-top {
display: flex;
align-items: baseline;
- gap: 8px;
+ gap: var(--space-8);
}
.paper-link {
color: var(--accent);
@@ -1134,15 +1170,15 @@ header#topBar {
}
.paper-date {
color: var(--text-dim);
- font-size: 0.68rem;
+ font-size: var(--text-xs);
flex-shrink: 0;
}
.paper-status {
- font-size: 0.62rem;
+ font-size: var(--text-2xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
- padding: 2px 6px;
+ padding: var(--space-2) var(--space-6);
border-radius: 4px;
flex-shrink: 0;
}
@@ -1155,27 +1191,27 @@ header#topBar {
.paper-meta {
color: var(--text-dim);
font-size: 0.7rem;
- margin-top: 4px;
+ margin-top: var(--space-4);
}
.paper-expanded-body {
- margin-top: 10px;
- padding-top: 10px;
+ margin-top: var(--space-10);
+ padding-top: var(--space-10);
border-top: 1px solid var(--border);
- font-size: 0.78rem;
+ font-size: var(--text-base);
color: var(--text-secondary);
line-height: 1.7;
display: none;
}
.paper-row.expanded .paper-expanded-body { display: block; }
.paper-claims {
- margin-top: 8px;
+ margin-top: var(--space-8);
}
.paper-claim-item {
- padding: 6px 10px;
+ padding: var(--space-6) var(--space-10);
background: var(--bg-base);
border-radius: var(--radius-sm);
- margin-bottom: 4px;
- font-size: 0.75rem;
+ margin-bottom: var(--space-4);
+ font-size: var(--text-md);
line-height: 1.5;
}
@@ -1186,13 +1222,13 @@ header#topBar {
.opp-list {
display: flex;
flex-direction: column;
- gap: 10px;
+ gap: var(--space-10);
max-height: calc(100vh - 240px);
overflow-y: auto;
}
.opp-card {
- padding: 16px 18px;
+ padding: var(--space-16) var(--space-18);
border-radius: var(--radius);
background: var(--bg-card);
border: 1px solid var(--border);
@@ -1201,18 +1237,18 @@ header#topBar {
.opp-card:hover {
border-color: var(--border-hover);
transform: translateY(-1px);
- box-shadow: 0 4px 16px rgba(0,0,0,0.05);
+ box-shadow: var(--shadow-sm);
}
.opp-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
- gap: 12px;
- margin-bottom: 8px;
+ gap: var(--space-12);
+ margin-bottom: var(--space-8);
}
.opp-title {
- font-size: 0.92rem;
+ font-size: var(--text-xl);
font-weight: 600;
font-family: var(--font-serif);
color: var(--text-primary);
@@ -1221,7 +1257,7 @@ header#topBar {
}
.opp-score {
flex-shrink: 0;
- font-size: 1.1rem;
+ font-size: var(--text-3xl);
font-weight: 800;
color: var(--gold);
min-width: 36px;
@@ -1233,46 +1269,46 @@ header#topBar {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
- padding: 2px 8px;
+ padding: var(--space-2) var(--space-8);
border-radius: 4px;
background: var(--gold-dim);
color: var(--gold);
- margin-bottom: 8px;
+ margin-bottom: var(--space-8);
}
.opp-desc {
color: var(--text-secondary);
- font-size: 0.82rem;
+ font-size: var(--text-lg);
line-height: 1.7;
- margin-bottom: 6px;
+ margin-bottom: var(--space-6);
}
.opp-why {
color: var(--green);
- font-size: 0.75rem;
+ font-size: var(--text-md);
font-weight: 500;
line-height: 1.5;
}
.opp-source {
color: var(--text-dim);
- font-size: 0.68rem;
- margin-top: 8px;
+ font-size: var(--text-xs);
+ margin-top: var(--space-8);
}
.opp-score-group {
display: flex;
- gap: 6px;
+ gap: var(--space-6);
flex-shrink: 0;
}
.opp-score-item {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 4px;
- padding: 2px 8px;
- font-size: 0.72rem;
+ padding: var(--space-2) var(--space-8);
+ font-size: var(--text-sm);
font-weight: 700;
color: var(--text-secondary);
}
.opp-section {
- margin-top: 10px;
+ margin-top: var(--space-10);
}
.opp-section-label {
font-size: 0.66rem;
@@ -1280,22 +1316,22 @@ header#topBar {
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
- margin-bottom: 4px;
+ margin-bottom: var(--space-4);
}
.opp-experiment {
color: var(--text-secondary);
- font-size: 0.82rem;
+ font-size: var(--text-lg);
line-height: 1.65;
}
.opp-impact {
color: var(--green);
- font-size: 0.82rem;
+ font-size: var(--text-lg);
line-height: 1.65;
font-style: italic;
}
.opp-footer {
- margin-top: 12px;
- padding-top: 8px;
+ margin-top: var(--space-12);
+ padding-top: var(--space-8);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
@@ -1303,37 +1339,37 @@ header#topBar {
}
.opp-evidence {
color: var(--text-secondary);
- font-size: 0.82rem;
+ font-size: var(--text-lg);
line-height: 1.6;
}
.opp-papers {
- margin: 4px 0 10px;
+ margin: var(--space-4) 0 var(--space-10);
}
/* ── Pattern Cards ────────────────────────────────────────────────── */
.patterns-list {
display: grid;
- gap: 10px;
+ gap: var(--space-10);
}
.pattern-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
- padding: 14px 16px;
+ padding: var(--space-14) var(--space-16);
}
.pattern-header {
display: flex;
align-items: center;
- gap: 8px;
- margin-bottom: 8px;
+ gap: var(--space-8);
+ margin-bottom: var(--space-8);
}
.pattern-level {
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
- padding: 2px 8px;
+ padding: var(--space-2) var(--space-8);
border-radius: 4px;
}
.pattern-level.universal {
@@ -1351,24 +1387,24 @@ header#topBar {
letter-spacing: 0.04em;
}
.pattern-text {
- font-size: 0.92rem;
+ font-size: var(--text-xl);
font-family: var(--font-serif);
font-style: italic;
color: var(--text-primary);
line-height: 1.55;
- margin-bottom: 8px;
+ margin-bottom: var(--space-8);
}
.pattern-domains {
- font-size: 0.72rem;
+ font-size: var(--text-sm);
color: var(--text-dim);
}
.pattern-domain-chip {
display: inline-block;
- padding: 1px 6px;
- margin: 2px 3px;
+ padding: 1px var(--space-6);
+ margin: var(--space-2) 3px;
background: var(--bg-elevated);
border-radius: 3px;
- font-size: 0.68rem;
+ font-size: var(--text-xs);
color: var(--text-secondary);
}
@@ -1381,12 +1417,12 @@ header#topBar {
flex: 1;
overflow-y: auto;
font-family: var(--font-mono);
- font-size: 0.72rem;
+ font-size: var(--text-sm);
line-height: 1.85;
}
.event {
- padding: 4px 10px;
+ padding: var(--space-4) var(--space-10);
border-left: 3px solid var(--border);
margin-bottom: 1px;
border-radius: 0 4px 4px 0;
@@ -1420,20 +1456,20 @@ header#topBar {
.providers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
- gap: 12px;
+ gap: var(--space-12);
}
.service-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: 10px;
- margin-bottom: 14px;
+ gap: var(--space-10);
+ margin-bottom: var(--space-14);
}
.service-card {
border: 1px solid var(--border);
border-radius: var(--radius);
- padding: 12px 14px;
+ padding: var(--space-12) var(--space-14);
background: var(--bg-card);
}
@@ -1441,18 +1477,18 @@ header#topBar {
display: flex;
justify-content: space-between;
align-items: center;
- gap: 10px;
- margin-bottom: 6px;
+ gap: var(--space-10);
+ margin-bottom: var(--space-6);
}
.service-title {
font-weight: 700;
- font-size: 0.82rem;
+ font-size: var(--text-lg);
color: var(--text-primary);
}
.service-state {
- font-size: 0.62rem;
+ font-size: var(--text-2xs);
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
@@ -1460,19 +1496,19 @@ header#topBar {
.service-detail {
color: var(--text-secondary);
- font-size: 0.72rem;
+ font-size: var(--text-sm);
line-height: 1.5;
}
.current-work-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
- gap: 10px;
+ gap: var(--space-10);
}
.work-lane {
border-top: 1px solid var(--border);
- padding-top: 10px;
+ padding-top: var(--space-10);
}
.work-lane-title {
@@ -1481,14 +1517,14 @@ header#topBar {
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
- margin-bottom: 8px;
+ margin-bottom: var(--space-8);
}
.work-item {
color: var(--text-secondary);
- font-size: 0.72rem;
+ font-size: var(--text-sm);
line-height: 1.45;
- margin-bottom: 8px;
+ margin-bottom: var(--space-8);
}
.work-item-title {
@@ -1500,36 +1536,36 @@ header#topBar {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
- padding: 16px 18px;
+ padding: var(--space-16) var(--space-18);
transition: border-color var(--transition);
}
.provider-card:hover { border-color: var(--border-hover); }
.provider-name {
- font-size: 0.92rem;
+ font-size: var(--text-xl);
font-weight: 600;
font-family: var(--font-serif);
color: var(--text-primary);
- margin-bottom: 4px;
+ margin-bottom: var(--space-4);
}
.provider-url {
- font-size: 0.68rem;
+ font-size: var(--text-xs);
color: var(--text-dim);
font-family: var(--font-mono);
- margin-bottom: 12px;
+ margin-bottom: var(--space-12);
word-break: break-all;
}
.provider-stats {
display: grid;
grid-template-columns: 1fr 1fr;
- gap: 8px;
+ gap: var(--space-8);
}
.provider-stat {
display: flex;
flex-direction: column;
}
.provider-stat-val {
- font-size: 1rem;
+ font-size: var(--text-2xl);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
@@ -1549,7 +1585,7 @@ header#topBar {
height: 4px;
background: var(--bg-base);
border-radius: 2px;
- margin-top: 12px;
+ margin-top: var(--space-12);
overflow: hidden;
}
.provider-bar {
@@ -1564,7 +1600,7 @@ header#topBar {
.empty-msg {
color: var(--text-dim);
font-style: italic;
- padding: 24px;
+ padding: var(--space-24);
text-align: center;
font-size: 0.8rem;
}
@@ -1574,7 +1610,7 @@ header#topBar {
flex-direction: column;
align-items: center;
justify-content: center;
- padding: 48px 24px;
+ padding: 48px var(--space-24);
color: var(--text-dim);
text-align: center;
}
@@ -1588,15 +1624,15 @@ header#topBar {
background: var(--bg-card);
border: 1px solid var(--border-hover);
border-radius: var(--radius);
- padding: 12px 14px;
- font-size: 0.75rem;
+ padding: var(--space-12) var(--space-14);
+ font-size: var(--text-md);
color: var(--text-primary);
max-width: 360px;
pointer-events: none;
opacity: 0;
z-index: 10000;
transition: opacity 0.12s ease;
- box-shadow: 0 8px 24px rgba(0,0,0,0.08);
+ box-shadow: var(--shadow-lg);
line-height: 1.5;
}
.tooltip.visible { opacity: 1; }
@@ -1654,8 +1690,8 @@ header#topBar {
background: var(--bg-card);
border: 1px solid var(--border-hover);
border-radius: var(--radius);
- box-shadow: 0 12px 32px rgba(0,0,0,0.12);
- padding: 14px 16px;
+ box-shadow: var(--shadow-xl);
+ padding: var(--space-14) var(--space-16);
z-index: 5;
animation: storyIn 0.16s ease;
}
@@ -1676,19 +1712,19 @@ header#topBar {
.story-flow-arrow { color: var(--text-muted); font-size: 0.7rem; }
.story-section { margin-bottom: 12px; }
.story-section-title {
- font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
- margin-bottom: 6px; display: flex; align-items: center; gap: 6px;
+ font-size: var(--text-sm); font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
+ margin-bottom: var(--space-6); display: flex; align-items: center; gap: 6px;
}
.story-section-title.gap { color: var(--green); }
.story-section-title.contra { color: var(--red); }
.story-section-title.disc { color: var(--accent); }
.story-count {
- font-size: 0.62rem; background: var(--bg-elevated); color: var(--text-dim);
+ font-size: var(--text-2xs); background: var(--bg-elevated); color: var(--text-dim);
border-radius: 999px; padding: 1px 7px; font-weight: 700;
}
.dg-story-panel ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.story-item {
- font-size: 0.78rem; color: var(--text-primary); line-height: 1.4;
+ font-size: var(--text-base); color: var(--text-primary); line-height: 1.4;
padding: 7px 9px; border-radius: var(--radius-sm); background: var(--bg-elevated);
border-left: 3px solid var(--border-hover);
}
@@ -1713,16 +1749,16 @@ header#topBar {
/* ── Insight Cards in Explore ─────────────────────────────────────── */
.insights-section {
- margin-top: 16px;
+ margin-top: var(--space-16);
}
.insights-list {
display: grid;
- gap: 12px;
+ gap: var(--space-12);
}
.insight-card {
background: var(--bg-card);
border-radius: var(--radius);
- padding: 16px 18px;
+ padding: var(--space-16) var(--space-18);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
}
@@ -1730,7 +1766,7 @@ header#topBar {
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: 8px;
+ margin-bottom: var(--space-8);
}
.insight-type {
font-size: 0.66rem;
@@ -1739,7 +1775,7 @@ header#topBar {
letter-spacing: 0.04em;
}
.insight-scores {
- font-size: 0.68rem;
+ font-size: var(--text-xs);
color: var(--text-dim);
font-weight: 600;
}
@@ -1748,34 +1784,34 @@ header#topBar {
font-weight: 600;
font-family: var(--font-serif);
color: var(--text-primary);
- margin-bottom: 8px;
+ margin-bottom: var(--space-8);
line-height: 1.45;
}
.insight-hypothesis {
color: var(--text-secondary);
- font-size: 0.82rem;
+ font-size: var(--text-lg);
line-height: 1.65;
- margin-bottom: 8px;
+ margin-bottom: var(--space-8);
}
.insight-evidence {
color: var(--text-secondary);
font-size: 0.8rem;
line-height: 1.6;
- margin-bottom: 8px;
- padding: 10px 12px;
+ margin-bottom: var(--space-8);
+ padding: var(--space-10) var(--space-12);
background: var(--bg-base);
border-radius: var(--radius-sm);
border-left: 2px solid var(--border);
}
.insight-experiment {
color: var(--text-dim);
- font-size: 0.78rem;
+ font-size: var(--text-base);
line-height: 1.55;
- margin-bottom: 6px;
+ margin-bottom: var(--space-6);
}
.insight-impact {
color: var(--green);
- font-size: 0.78rem;
+ font-size: var(--text-base);
line-height: 1.55;
font-style: italic;
}
@@ -1787,16 +1823,16 @@ header#topBar {
letter-spacing: 0.03em;
}
.insight-papers {
- margin: 4px 0 10px;
+ margin: var(--space-4) 0 var(--space-10);
}
/* ── Paper Citation Links ─────────────────────────────────────────── */
.paper-cite {
display: inline-block;
- padding: 2px 8px;
- margin: 2px 4px 2px 0;
- font-size: 0.72rem;
+ padding: var(--space-2) var(--space-8);
+ margin: var(--space-2) var(--space-4) var(--space-2) 0;
+ font-size: var(--text-sm);
font-weight: 600;
color: var(--accent);
background: var(--accent-dim);
@@ -1852,16 +1888,16 @@ header#topBar {
.insight-actions {
display: flex;
- gap: 8px;
- margin-top: 12px;
+ gap: var(--space-8);
+ margin-top: var(--space-12);
}
.btn-research {
background: #1a5c3a;
color: #d4f0e0;
border: 1px solid #2a7a50;
border-radius: 6px;
- padding: 6px 14px;
- font-size: 0.75rem;
+ padding: var(--space-6) var(--space-14);
+ font-size: var(--text-md);
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
@@ -1875,8 +1911,8 @@ header#topBar {
color: #8a9aaa;
border: 1px solid #2a3a4a;
border-radius: 6px;
- padding: 6px 14px;
- font-size: 0.75rem;
+ padding: var(--space-6) var(--space-14);
+ font-size: var(--text-md);
cursor: pointer;
transition: all 0.15s;
}
@@ -1913,18 +1949,18 @@ header#topBar {
overflow: hidden;
}
.proposal-header {
- padding: 18px 24px;
+ padding: var(--space-18) var(--space-24);
border-bottom: 1px solid #1a2a3a;
position: relative;
}
.proposal-header h3 {
font-size: 1.05rem;
color: #e8f0f8;
- margin: 0 0 6px 0;
+ margin: 0 0 var(--space-6) 0;
padding-right: 40px;
}
.proposal-stats {
- font-size: 0.72rem;
+ font-size: var(--text-sm);
color: #6a7a8a;
}
.btn-close {
@@ -1943,8 +1979,8 @@ header#topBar {
.proposal-body {
flex: 1;
overflow-y: auto;
- padding: 20px 24px;
- font-size: 0.78rem;
+ padding: var(--space-20) var(--space-24);
+ font-size: var(--text-base);
line-height: 1.7;
color: #b0c0d0;
word-wrap: break-word;
@@ -1978,7 +2014,7 @@ header#topBar {
color: #8a9ab0;
}
.proposal-footer {
- padding: 14px 24px;
+ padding: var(--space-14) var(--space-24);
border-top: 1px solid #1a2a3a;
display: flex;
justify-content: flex-end;
@@ -1986,9 +2022,9 @@ header#topBar {
/* ── Paradigm Score Badges ────────────────────────────────────── */
.paradigm-badge {
- font-size: 0.68rem;
+ font-size: var(--text-xs);
font-weight: 700;
- padding: 2px 7px;
+ padding: var(--space-2) 7px;
border-radius: 4px;
letter-spacing: 0.03em;
}
@@ -2009,9 +2045,9 @@ header#topBar {
}
.insight-rationale {
color: #8a9aaa;
- font-size: 0.75rem;
+ font-size: var(--text-md);
font-style: italic;
- margin-bottom: 8px;
+ margin-bottom: var(--space-8);
line-height: 1.5;
}
diff --git a/web/static/js/app.js b/web/static/js/app.js
index 919e36c..05811f7 100644
--- a/web/static/js/app.js
+++ b/web/static/js/app.js
@@ -18,13 +18,11 @@ let events = []; // max 50
let activePapers = {}; // paper_id -> {title, step, startTime}
let statsCache = null;
let allPapers = [];
-let allOpportunities = [];
let taxonomyFlat = []; // flat list for Evidence dropdown
let searchTimer = null;
let statsTimer = null;
let providerTimer = null;
let papersLoaded = false;
-let oppsLoaded = false;
let providersLoaded = false;
let taxonomyLoaded = false;
let paperProgressLoaded = false;
@@ -959,6 +957,47 @@ async function loadEvidenceForNode(nodeId) {
}
}
+// ── Heatmap colour scale ──────────────────────────────────────────────
+// The benchmark matrix shades every filled (non-SOTA) cell by its value, so
+// the table reads as a real heatmap instead of one flat fill. The two scale
+// endpoints live in :root as CSS variables (--heat-lo / --heat-hi) so the
+// palette stays themeable; we read them once and lerp in RGB space. SOTA cells
+// keep their distinct green styling untouched — the only visible change is the
+// gradient on ordinary filled cells.
+function _hexToRgb(hex) {
+ const h = String(hex).trim().replace('#', '');
+ const s = h.length === 3 ? h.split('').map(c => c + c).join('') : h;
+ const n = parseInt(s, 16);
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
+}
+function _lerpRgb(a, b, t) {
+ return `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)}, ${Math.round(a[1] + (b[1] - a[1]) * t)}, ${Math.round(a[2] + (b[2] - a[2]) * t)})`;
+}
+// Build a value→background-colour mapper for one metric. Computes min/max over
+// the filled, non-SOTA cells of that metric in a single O(cells) pass so a
+// large matrix never costs more than its size.
+function buildMatrixHeat(matrix, metric) {
+ const cs = getComputedStyle(document.documentElement);
+ const lo = _hexToRgb((cs.getPropertyValue('--heat-lo').trim() || '#faf1ea'));
+ const hi = _hexToRgb((cs.getPropertyValue('--heat-hi').trim() || '#e0a883'));
+ let min = Infinity, max = -Infinity;
+ const suffix = `|||${metric}`;
+ for (const key in matrix.cells) {
+ if (!key.endsWith(suffix)) continue;
+ const c = matrix.cells[key];
+ if (c.is_sota || c.value == null) continue;
+ const v = Number(c.value);
+ if (v < min) min = v;
+ if (v > max) max = v;
+ }
+ const span = max - min;
+ return (value) => {
+ if (value == null) return '';
+ const t = span > 0 ? (Number(value) - min) / span : 0.5;
+ return _lerpRgb(lo, hi, Math.max(0, Math.min(1, t)));
+ };
+}
+
function renderMatrix(container, matrix) {
if (!matrix.methods.length || !matrix.datasets.length) {
container.innerHTML = `${esc(tr('empty.resultData'))}
`;
@@ -973,6 +1012,7 @@ function renderMatrix(container, matrix) {
}
const metrics = Object.keys(metricCounts).sort((a, b) => metricCounts[b] - metricCounts[a]);
const defaultMetric = metrics[0] || '';
+ const heat = buildMatrixHeat(matrix, defaultMetric);
let html = '';
html += `
${esc(tr('common.metric'))} `;
@@ -1000,7 +1040,9 @@ function renderMatrix(container, matrix) {
if (cell) {
const cls = cell.is_sota ? 'cell-sota' : 'cell-filled';
const val = cell.value != null ? Number(cell.value).toFixed(1) : '-';
- html += `
${val} `;
+ const bg = (!cell.is_sota && cell.value != null) ? heat(cell.value) : '';
+ const style = bg ? ` style="background:${bg}"` : '';
+ html += `
${val} `;
} else {
html += `
- `;
}
@@ -1019,6 +1061,7 @@ function updateMatrixMetric(selectEl) {
if (!matrix) return;
const metric = selectEl.value;
+ const heat = buildMatrixHeat(matrix, metric);
const rows = container.querySelectorAll('tbody tr');
rows.forEach((row, mi) => {
@@ -1032,10 +1075,12 @@ function updateMatrixMetric(selectEl) {
const val = cell.value != null ? Number(cell.value).toFixed(1) : '-';
td.textContent = val;
td.className = 'matrix-cell ' + (cell.is_sota ? 'cell-sota' : 'cell-filled');
+ td.style.background = (!cell.is_sota && cell.value != null) ? heat(cell.value) : '';
td.title = `${tr('common.onDataset', { method, dataset: ds })}: ${val}`;
} else {
td.textContent = '-';
td.className = 'matrix-cell cell-empty';
+ td.style.background = '';
td.title = tr('common.noData');
}
});
@@ -1087,7 +1132,7 @@ function renderPapers() {
return;
}
- setListHtmlChunked(list, filtered.map(p => {
+ renderListChunked(list, filtered, p => {
const sc = p.status ? 's-' + p.status : '';
return `
@@ -1100,7 +1145,7 @@ function renderPapers() {
${esc(tr('common.loadingDetails'))}
`;
- }));
+ });
}
// ── Paper Progress Tabs ─────────────────────────────────────────────
@@ -1153,7 +1198,7 @@ function renderPaperPipelineRows(rows) {
list.innerHTML = `
${esc(tr('empty.paperProgress'))}
`;
return;
}
- setListHtmlChunked(list, papers.map(item => `
+ renderListChunked(list, papers, item => `
${esc(trunc(item.title || item.id || tr('common.untitledPaper'), 120))}
@@ -1166,7 +1211,7 @@ function renderPaperPipelineRows(rows) {
${item.stage_last_error ? `
${esc(trunc(item.stage_last_error, 240))}
` : ''}
- `));
+ `);
}
function renderPaperGenerationRows(jobs, manuscripts) {
@@ -1280,7 +1325,7 @@ function renderGeneratedPapers(manuscripts) {
list.innerHTML = `
${esc(tr('empty.generated'))}
`;
return;
}
- setListHtmlChunked(list, rows.map(row => {
+ renderListChunked(list, rows, row => {
const preview = row.deep_insight_id ? paperPreviewHref(row.deep_insight_id, 'index') : '';
return `
@@ -1305,7 +1350,7 @@ function renderGeneratedPapers(manuscripts) {
`;
- }));
+ });
}
async function loadGeneratedPapersTab() {
@@ -1411,7 +1456,7 @@ async function loadInsightsTab() {
return;
}
- setListHtmlChunked(list, insights.map(ins => {
+ renderListChunked(list, insights, ins => {
const color = typeColors[ins.insight_type] || '#888';
let papers = [];
try { papers = JSON.parse(ins.supporting_papers || '[]'); } catch(e) {}
@@ -1441,115 +1486,13 @@ async function loadInsightsTab() {
${esc(tr('label.previewProposal'))}
`;
- }));
+ });
} catch (e) {
insightsLoaded = false;
console.error('Insights tab error:', e);
}
}
-async function loadOpportunities() {
- oppsLoaded = true;
- try {
- // Load deep research insights
- const insights = await api('/api/insights?limit=100');
- allOpportunities = insights;
- allOpportunities.sort((a, b) =>
- ((b.novelty_score||0) + (b.feasibility_score||0)) - ((a.novelty_score||0) + (a.feasibility_score||0))
- );
- renderOpportunities();
- } catch (e) {
- console.error('Opportunities load error:', e);
- }
-}
-
-const insightTypeColors = {
- contradiction_analysis: { color: '#c4453a', labelKey: 'insights.type.contradiction' },
- method_transfer: { color: '#c4704b', labelKey: 'insights.type.methodTransfer' },
- assumption_challenge: { color: '#a8842a', labelKey: 'insights.type.assumptionChallenge' },
- ignored_limitation: { color: '#7c5cbf', labelKey: 'insights.type.ignoredLimitation' },
- paradigm_exhaustion: { color: '#9a9088', labelKey: 'insights.type.paradigmExhaustion' },
- cross_domain_bridge: { color: '#2e86ab', labelKey: 'insights.type.crossDomainBridge' },
-};
-
-function insightTypeLabel(type) {
- const meta = insightTypeColors[type] || {};
- return meta.labelKey ? tr(meta.labelKey) : String(type || '').replace(/_/g, ' ');
-}
-
-function renderOpportunities() {
- const list = el('oppList');
- const typeFilter = el('oppTypeFilter').value;
-
- // Rebuild filter dropdown with actual insight types
- const select = el('oppTypeFilter');
- const currentVal = select.value;
- if (select.options.length <= 1 && allOpportunities.length > 0) {
- // Clear old hardcoded options
- select.innerHTML = `${esc(tr('insights.allTypes'))} `;
- const types = [...new Set(allOpportunities.map(o => o.insight_type))].filter(Boolean).sort();
- for (const t of types) {
- const meta = insightTypeColors[t] || {};
- const opt = document.createElement('option');
- opt.value = t;
- opt.textContent = insightTypeLabel(t);
- select.appendChild(opt);
- }
- if (currentVal) select.value = currentVal;
- }
-
- let filtered = allOpportunities;
- if (typeFilter) {
- filtered = filtered.filter(o => o.insight_type === typeFilter);
- }
-
- if (filtered.length === 0) {
- list.innerHTML = `${esc(tr('empty.opportunities'))}
`;
- return;
- }
-
- list.innerHTML = filtered.map(ins => {
- const meta = insightTypeColors[ins.insight_type] || { color: '#888' };
- // Parse supporting papers for links
- let papers = [];
- try { papers = JSON.parse(ins.supporting_papers || '[]'); } catch(e) {}
- const paperLinks = papers.map(pid =>
- `${esc(pid)} `
- ).join(' ');
-
- return `
-
${esc(insightTypeLabel(ins.insight_type))}
-
- ${paperLinks ? `
${paperLinks}
` : ''}
- ${ins.evidence ? `
-
${esc(tr('label.evidence').replace(/:$/, ''))}
-
${esc(ins.evidence)}
-
` : ''}
-
-
${esc(tr('label.hypothesis').replace(/:$/, ''))}
-
${esc(ins.hypothesis)}
-
- ${ins.experiment ? `
-
${esc(tr('label.proposedExperiment'))}
-
${esc(ins.experiment)}
-
` : ''}
- ${ins.impact ? `
-
${esc(tr('label.potentialImpact'))}
-
${esc(ins.impact)}
-
` : ''}
-
-
`;
- }).join('');
-}
-
// ── Discoveries Tab (Tier 1 + Tier 2) ────────────────────────────────
async function loadDiscoveriesTab() {
@@ -1584,7 +1527,7 @@ function renderDiscoveries(discoveries) {
return;
}
- setListHtmlChunked(list, visible.map(d => {
+ renderListChunked(list, visible, d => {
const isTier1 = d.tier === 1;
const tierColor = isTier1 ? '#c4453a' : '#2e86ab';
const tierLabel = isTier1 ? tr('discoveries.tier1') : tr('discoveries.tier2');
@@ -1683,7 +1626,7 @@ function renderDiscoveries(discoveries) {
${d.evidence_summary ? `${esc(tr('label.evidence'))} ${esc(trunc(d.evidence_summary, 250))}
` : ''}
${esc(tr('label.mode'))} ${esc(tr('label.fixedAutomaticPipeline'))}
`;
- }));
+ });
}
// ── Experiments Tab ───────────────────────────────────────────────────
@@ -1848,7 +1791,7 @@ function renderAutoResearchJobs(jobs) {
failed: '#c4453a',
};
- list.innerHTML = jobs.map(j => {
+ renderListChunked(list, jobs, j => {
const color = colors[j.status] || '#888';
const cpu = j.cpu_eligible == null
? tr('status.cpu.unchecked')
@@ -1874,7 +1817,7 @@ function renderAutoResearchJobs(jobs) {
${j.last_note ? `${esc(tr('common.latest'))} ${esc(trunc(j.last_note, 220))}
` : ''}
${j.last_error ? `${esc(tr('common.error'))} ${esc(trunc(j.last_error, 220))}
` : ''}
`;
- }).join('');
+ });
}
function renderExperiments(runs) {
@@ -1884,14 +1827,14 @@ function renderExperiments(runs) {
return;
}
- list.innerHTML = runs.map(r => {
- const statusColors = {
- pending: '#9a9088', scaffolding: '#a8842a', reproducing: '#2e86ab',
- testing: '#c4704b', completed: '#3d8b5e', failed: '#c4453a'
- };
- const verdictColors = {
- confirmed: '#3d8b5e', refuted: '#c4453a', inconclusive: '#a8842a'
- };
+ const statusColors = {
+ pending: '#9a9088', scaffolding: '#a8842a', reproducing: '#2e86ab',
+ testing: '#c4704b', completed: '#3d8b5e', failed: '#c4453a'
+ };
+ const verdictColors = {
+ confirmed: '#3d8b5e', refuted: '#c4453a', inconclusive: '#a8842a'
+ };
+ renderListChunked(list, runs, r => {
const color = statusColors[r.status] || '#888';
const vColor = verdictColors[r.hypothesis_verdict] || '#888';
@@ -1918,7 +1861,7 @@ function renderExperiments(runs) {
${esc(tr('common.viewDetails'))}
`;
- }).join('');
+ });
}
function experimentStatusColor(status) {
@@ -1967,7 +1910,7 @@ function renderExperimentGroupsV2(groups) {
return;
}
- setListHtmlChunked(list, groups.map(group => {
+ renderListChunked(list, groups, group => {
const insight = group.insight || {};
const auto = group.auto_job || {};
const currentRun = group.canonical_run || group.latest_run || null;
@@ -2028,7 +1971,7 @@ function renderExperimentGroupsV2(groups) {
${previewUrl ? `${esc(tr('experiments.openManuscript'))} ` : ''}
`;
- }));
+ });
}
function jsonPreview(obj, emptyText = 'None') {
@@ -2194,31 +2137,40 @@ function runWhenIdle(fn, timeout = 700) {
}
}
-// Render a large list of pre-built HTML strings without janking the main
-// thread. A single `container.innerHTML = parts.join('')` of a few hundred
-// complex cards parses/builds the whole subtree in one ~hundreds-of-ms task;
-// during the idle prefetch several such renders ran back-to-back and made the
-// page feel frozen. Here we drop the first chunk in synchronously (so the tab
-// is not empty) and append the rest across idle callbacks. insertAdjacentHTML
-// only parses the appended slice, so total work stays O(n) — never the O(n^2)
-// of `innerHTML +=`. Items use inline onclick handlers, so no post-render
-// event binding is needed.
-function setListHtmlChunked(container, parts, chunk = 25) {
+// Render a large list of cards without janking the main thread. A single
+// `container.innerHTML = items.map(renderItem).join('')` does TWO O(n) bursts of
+// work in one synchronous task: it builds every card's HTML string (often
+// parsing several JSON fields per row — the production /api/deep_insights and
+// /api/insights payloads are ~0.6–0.7 MB) AND parses the whole subtree into the
+// DOM. On real data that single task ran into the hundreds of ms. Here we build
+// AND insert one chunk at a time: the first chunk synchronously (so the tab is
+// never empty), the rest across idle callbacks. Each step is O(chunk), so no
+// step is a long task, and insertAdjacentHTML only parses the appended slice so
+// total work stays O(n) — never the O(n²) of `innerHTML +=`. `renderItem(item,
+// i)` returns the card HTML; items use inline onclick handlers, so no
+// post-render event binding is needed.
+function renderListChunked(container, items, renderItem, chunk = 25) {
container.innerHTML = '';
- if (!parts || !parts.length) return;
+ if (!items || !items.length) return;
// Cancel any still-pending chunked render of an earlier call (e.g. when a
// filter re-renders the same list before the previous run finished).
const token = (container._chunkToken || 0) + 1;
container._chunkToken = token;
- container.insertAdjacentHTML('beforeend', parts.slice(0, chunk).join(''));
+ const buildSlice = (start) => {
+ let html = '';
+ const end = Math.min(start + chunk, items.length);
+ for (let i = start; i < end; i++) html += renderItem(items[i], i);
+ return html;
+ };
+ container.insertAdjacentHTML('beforeend', buildSlice(0));
let i = chunk;
const step = () => {
if (container._chunkToken !== token) return; // superseded
- container.insertAdjacentHTML('beforeend', parts.slice(i, i + chunk).join(''));
+ container.insertAdjacentHTML('beforeend', buildSlice(i));
i += chunk;
- if (i < parts.length) runWhenIdle(step, 50);
+ if (i < items.length) runWhenIdle(step, 50);
};
- if (i < parts.length) runWhenIdle(step, 50);
+ if (i < items.length) runWhenIdle(step, 50);
}
async function prefetchInactiveTabs() {