diff --git a/simulator/static/app.js b/simulator/static/app.js new file mode 100644 index 0000000..12a77f2 --- /dev/null +++ b/simulator/static/app.js @@ -0,0 +1,111 @@ +const DIALS = ["left", "right", "dark", "light"]; +const MODEL = ["brain_weight", "mood_weight", "pool_size"]; + +function buildGrid(el) { + el.innerHTML = ""; + // rows = first axis 0..4 top->bottom, cols = second axis 0..4 left->right + for (let a = 0; a < 5; a++) { + for (let b = 0; b < 5; b++) { + const cell = document.createElement("div"); + cell.className = "cell"; + cell.dataset.a = a; + cell.dataset.b = b; + el.appendChild(cell); + } + } +} + +function paintGrid(el, axisA, axisB, point, pool) { + // clear + el.querySelectorAll(".cell").forEach((c) => { + c.className = "cell"; + c.innerHTML = ""; + }); + const counts = {}; + pool.forEach((c) => { + const r = c.record; + const key = `${r[axisA]},${r[axisB]}`; + counts[key] = (counts[key] || 0) + 1; + }); + el.querySelectorAll(".cell").forEach((c) => { + const a = +c.dataset.a, b = +c.dataset.b; + const key = `${a},${b}`; + if (counts[key]) { + c.classList.add("cand"); + const n = document.createElement("span"); + n.className = "n"; + n.textContent = counts[key]; + c.appendChild(n); + } + if (a === point[axisA] && b === point[axisB]) c.classList.add("point"); + }); +} + +function readState() { + const s = { mode: document.getElementById("mode").value, approved_only: document.getElementById("approved_only").checked }; + DIALS.forEach((d) => (s[d] = +document.getElementById(d).value)); + s.brain_weight = +document.getElementById("brain_weight").value; + s.mood_weight = +document.getElementById("mood_weight").value; + s.pool_size = +document.getElementById("pool_size").value; + return s; +} + +function syncOutputs() { + [...DIALS, ...MODEL].forEach((id) => { + const out = document.getElementById(`${id}-out`); + if (out) out.textContent = document.getElementById(id).value; + }); +} + +async function refresh() { + syncOutputs(); + const state = readState(); + const resp = await fetch("/api/select", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state), + }); + const data = await resp.json(); + + const pickEl = document.getElementById("pick"); + if (!data.pick) { + pickEl.innerHTML = '
∅ Void / rest — walls dark, audio silent.
'; + } else { + const p = data.pick; + pickEl.innerHTML = + `
${p.title}
` + + `
mode ${p.mode} · coord (${p.left},${p.right},${p.dark},${p.light})
` + + `
${p.rationale || ""}
`; + } + + const poolEl = document.getElementById("pool"); + poolEl.innerHTML = ""; + data.pool.forEach((c, i) => { + const li = document.createElement("li"); + if (i === 0) li.className = "winner"; + li.innerHTML = `${c.record.title} d=${c.distance.toFixed(2)}`; + poolEl.appendChild(li); + }); + + const point = { left: state.left, right: state.right, dark: state.dark, light: state.light }; + paintGrid(document.getElementById("brain-grid"), "left", "right", point, data.pool); + paintGrid(document.getElementById("mood-grid"), "dark", "light", point, data.pool); +} + +async function loadMeta() { + const data = await (await fetch("/api/catalog/meta")).json(); + const byMode = Object.entries(data.by_mode).map(([k, v]) => `${k}:${v}`).join(" "); + document.getElementById("meta").textContent = `${data.total} records · ${byMode}`; +} + +function init() { + buildGrid(document.getElementById("brain-grid")); + buildGrid(document.getElementById("mood-grid")); + document.querySelectorAll("input, select").forEach((el) => + el.addEventListener("input", refresh) + ); + loadMeta(); + refresh(); +} + +document.addEventListener("DOMContentLoaded", init); diff --git a/simulator/static/index.html b/simulator/static/index.html new file mode 100644 index 0000000..0a89203 --- /dev/null +++ b/simulator/static/index.html @@ -0,0 +1,64 @@ + + + + + + HEF — Curator's X-ray + + + +
+

Experience Filter — Curator's X-ray

+
+
+ +
+
+

Dials

+ + + + + + +

Model knobs

+ + + + +
+ +
+
+

Picked

+
+
+
+

Pool (nearest first)

+
    +
    +
    +

    Coordinate maps

    +
    Brain — Left × Right
    +
    Mood — Dark × Light
    +
    +
    +
    + + + + diff --git a/simulator/static/style.css b/simulator/static/style.css new file mode 100644 index 0000000..79371ce --- /dev/null +++ b/simulator/static/style.css @@ -0,0 +1,26 @@ +:root { color-scheme: dark; } +* { box-sizing: border-box; } +body { margin: 0; font-family: -apple-system, system-ui, sans-serif; background: #0e0e16; color: #e6e6ee; } +header { padding: 12px 20px; border-bottom: 1px solid #2a2a3a; display: flex; justify-content: space-between; align-items: baseline; } +header h1 { font-size: 18px; margin: 0; } +.meta { font-size: 12px; color: #9a9ab0; } +main { display: grid; grid-template-columns: 280px 1fr; gap: 20px; padding: 20px; } +.controls label { display: block; margin: 8px 0; font-size: 13px; } +.controls input[type=range] { width: 100%; } +.controls .check { display: flex; gap: 6px; align-items: center; } +h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .5px; color: #9a9ab0; } +.xray { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; } +.pick #pick { background: #1a1a2e; border: 1px solid #33334a; border-radius: 8px; padding: 14px; min-height: 80px; } +.pick .title { font-size: 16px; font-weight: 600; } +.pick .void { color: #7777aa; font-style: italic; } +.pool ol { margin: 0; padding-left: 18px; font-size: 13px; } +.pool li { margin: 4px 0; } +.pool li.winner { color: #7fffd4; font-weight: 600; } +.pool .dist { color: #9a9ab0; } +.maps { grid-column: 1 / -1; display: flex; gap: 40px; } +.grid5 { display: grid; grid-template-columns: repeat(5, 28px); grid-template-rows: repeat(5, 28px); gap: 3px; } +.grid5 .cell { background: #1c1c2c; border: 1px solid #2a2a3a; border-radius: 3px; position: relative; } +.grid5 .cell.cand { background: #3a3a66; } +.grid5 .cell.point { outline: 2px solid #7fffd4; } +.grid5 .cell .n { position: absolute; right: 2px; bottom: 1px; font-size: 9px; color: #aab; } +.label { font-size: 11px; color: #9a9ab0; margin-bottom: 4px; } diff --git a/tests/test_simulator_api.py b/tests/test_simulator_api.py index e476617..57e6b24 100644 --- a/tests/test_simulator_api.py +++ b/tests/test_simulator_api.py @@ -88,3 +88,15 @@ def test_catalog_meta_reports_counts(client): assert data["by_mode"]["video"] == 4 assert data["by_mode"]["audio"] == 1 assert set(data["by_status"]) == {"proposed", "approved"} + + +from simulator.app import create_app as _create_app_for_static + + +def test_index_is_served(): + # The default app mounts the real static dir. + client = TestClient(_create_app_for_static()) + resp = client.get("/") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + assert "X-ray" in resp.text