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) => `

${it.title}

row ${it.i}

`; + 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 += ``; @@ -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() { `; - })); + }); } 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 = ``; - 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))}
-
-
${esc(ins.title)}
-
- N:${ins.novelty_score || '?'}/5 - F:${ins.feasibility_score || '?'}/5 -
-
- ${paperLinks ? `
${paperLinks}
` : ''} - ${ins.evidence ? `
- -
${esc(ins.evidence)}
-
` : ''} -
- -
${esc(ins.hypothesis)}
-
- ${ins.experiment ? `
- -
${esc(ins.experiment)}
-
` : ''} - ${ins.impact ? `
- -
${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) { `; - }).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 ? `` : ''} `; - })); + }); } 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() {