diff --git a/.gitignore b/.gitignore index 0f9ce5a..059ed74 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ testsprite_tests/tmp/config.json .vercel .env* + +# ghost recorder state (sessions, shadow index, patches) +.ghost/ diff --git a/packages/ghost/ghost.mjs b/packages/ghost/ghost.mjs index d2e249d..c3fec0b 100644 --- a/packages/ghost/ghost.mjs +++ b/packages/ghost/ghost.mjs @@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url"; import * as shadow from "./lib/shadowgit.mjs"; import * as store from "./lib/store.mjs"; import { createProbes } from "./lib/probes.mjs"; +import { cityHtml3d } from "./lib/city.mjs"; const ROOT = process.cwd(); const GHOST_DIR = path.join(ROOT, ".ghost"); @@ -195,245 +196,8 @@ tick();setInterval(tick,1600);addEventListener("resize",draw); `; } -// Ghost City 3D — same layout language as the published playground (3D city in -// the center, info panel left, selected-tower panel right, turn dock bottom), -// but stream-fed from the recorder's own /session, so ANY project gets the -// full experience from `ghost watch` alone. three.js is loaded from a CDN in -// the browser (the recorder itself stays zero-dependency); if the browser is -// offline the 2D canvas city at /city2d is still served by the recorder. -function cityHtml3d() { - return ` - -Ghost City — live playground (3D) - - - -

Ghost City

connecting… - studio · session · 2D fallback -
-
- -
- -
-
turns
-`; -} +// Ghost City (the /city route) lives in ./lib/city.mjs so the recorder and the +// published /playground share one shell: tape rail, 3D city, autopsy, dock. // ── helpers ────────────────────────────────────────────────────────────────── // Parse a unified diff into per-file churn (added+deleted lines) used as the @@ -553,22 +317,23 @@ async function cmdWatch() { const port = Number(arg("--recorder-port", config.recorderPort || DEFAULT_RECORDER_PORT)); const eventsBase = `http://127.0.0.1:${port}`; const emitter = http.createServer((req, res) => { - if (req.method === "GET" && (req.url === "/" || req.url === "/studio")) { + const pathname = (req.url || "/").split("?")[0]; + if (req.method === "GET" && (pathname === "/" || pathname === "/studio")) { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(studioHtml(eventsBase)); return; } - if (req.method === "GET" && req.url === "/city") { + if (req.method === "GET" && pathname === "/city") { res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); res.end(cityHtml3d()); return; } - if (req.method === "GET" && req.url === "/city2d") { + if (req.method === "GET" && pathname === "/city2d") { res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); res.end(cityHtml()); return; } - if (req.method === "GET" && req.url === "/session") { + if (req.method === "GET" && pathname === "/session") { res.writeHead(200, { "content-type": "application/json; charset=utf-8", "access-control-allow-origin": "*", @@ -576,8 +341,8 @@ async function cmdWatch() { res.end(JSON.stringify(session)); return; } - if (req.method === "GET" && req.url.startsWith("/diff/")) { - const name = decodeURIComponent(req.url.slice(6).split("?")[0]); + if (req.method === "GET" && pathname.startsWith("/diff/")) { + const name = decodeURIComponent(pathname.slice(6)).slice(0, 12); const dir = store.sessionDir(ROOT, session); try { const file = fs.readdirSync(path.join(dir, "diffs")).find((f) => f.startsWith(name)); @@ -590,7 +355,7 @@ async function cmdWatch() { } catch { res.writeHead(404).end(); return; } return; } - if (req.method !== "POST" || req.url !== "/events") { res.writeHead(404).end(); return; } + if (req.method !== "POST" || pathname !== "/events") { res.writeHead(404).end(); return; } let body = ""; req.on("data", (c) => (body += c)); req.on("end", () => { diff --git a/packages/ghost/lib/city-client.mjs b/packages/ghost/lib/city-client.mjs new file mode 100644 index 0000000..632117a --- /dev/null +++ b/packages/ghost/lib/city-client.mjs @@ -0,0 +1,819 @@ + +// Ghost City — live playground client. Mirrors /playground: tape rail, 3D code +// city, autopsy panel and timeline dock, fed by the recorder's own /session. +const $ = (id) => document.getElementById(id); +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + +const GHOST = (size, cls) => + ''; + +const icon = (d, size) => + ''; + +const ICON = { + robot: icon(''), + user: icon(''), + warning: icon(''), + check: icon(''), + play: icon('', 16), + pause: icon('', 16), + left: icon('', 16), + right: icon('', 16), + refresh: icon(''), + back: icon('', 16), + broadcast: icon(''), + cube: icon(''), + map: icon(''), + crosshair: icon(''), + file: icon(''), + x: icon(''), + undo: icon(''), +}; + +const state = { + raw: null, demo: false, first: true, + id: "—", repo: "—", head: null, agent: "ghost watch · local", + turns: [], towers: [], samples: [], anomaly: null, blamedPath: null, + active: 0, selected: null, playing: false, speed: 1, preset: "overview", + diffCache: new Map(), sig: "", seq: 0, +}; +let timer = null; + +const hash = (value) => { + let out = 2166136261; + for (let i = 0; i < value.length; i += 1) { out ^= value.charCodeAt(i); out = Math.imul(out, 16777619); } + return (out >>> 0) / 4294967296; +}; +const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); +const timecode = (turn) => (turn && turn.ts ? new Date(turn.ts).toISOString().slice(11, 19) : "00:00:00"); + +// ── data ───────────────────────────────────────────────────────────────────── +function normalize(raw) { + const checkpoints = raw.checkpoints || []; + const samples = raw.samples || []; + const perTurn = new Map(); + for (const cp of checkpoints) { + const list = perTurn.get(cp.turnNumber) || []; + perTurn.set(cp.turnNumber, list.concat(cp.files || [])); + } + const turns = (raw.turns || []).map((t, i) => { + const n = t.index ?? i + 1; + const cp = checkpoints.find((c) => c.turnNumber === n && c.sha && !String(c.sha).startsWith("nogit-")); + return { + index: i, n, + role: t.id ? "assistant" : "user", + text: t.prompt || (t.id ? "[assistant turn " + String(t.id).slice(0, 8) + "]" : "[no prompt captured]"), + files: [...new Set([...(t.files || []), ...(perTurn.get(n) || [])])], + ts: t.startedAt || t.ts || null, + sha: cp ? cp.sha : null, + estimated: !!t.estimated, + }; + }); + + const seen = new Map(); + for (const cp of checkpoints) { + for (const f of cp.files || []) { + if (!f) continue; + const cur = seen.get(f) || { path: f, loc: 0, git: false, turn: cp.turnNumber || 1, ts: cp.ts, sha: cp.sha, cps: 0 }; + const churn = cp.churn ? cp.churn[f] : null; + if (churn != null) { + cur.loc += churn; cur.git = true; + if (!cur.sha || String(cur.sha).startsWith("nogit-")) cur.sha = cp.sha; + } + cur.cps += 1; + seen.set(f, cur); + } + } + const spacing = 3.05, cols = 10, rows = 8; + const towers = [...seen.values()].sort((a, b) => String(a.ts).localeCompare(String(b.ts))).map((t) => { + const col = Math.floor(hash(t.path) * cols); + const row = Math.floor(hash(t.path + "·z") * rows); + return { + ...t, + owner: "ai", + x: (col - (cols - 1) / 2) * spacing, + z: (row - (rows - 1) / 2) * spacing, + w: 0.8 + (hash(t.path + "w") % 0.3) * 0.9, + d: 0.8 + (hash(t.path + "d") % 0.3) * 0.9, + h: t.git ? clamp(t.loc / 7 + 2.2, 1.6, 12) : 2.0, + }; + }); + + const anomaly = detect(samples); + let blamedPath = null; + if (anomaly) { + const cp = checkpoints.filter((c) => c.turnNumber === anomaly.turn); + let best = null; + for (const c of cp) for (const [p, v] of Object.entries(c.churn || {})) if (!best || v > best.v) best = { p, v }; + blamedPath = best ? best.p : null; + } + + return { + id: raw.id || "session", repo: raw.repo || "—", head: raw.head || null, + agent: raw.demo ? "demo fixture" : "ghost watch · local", + turns, towers, samples, anomaly, blamedPath, + }; +} + +function detect(samples) { + if (!samples || samples.length < 3) return null; + let best = null; + for (let i = 1; i < samples.length; i += 1) { + const a = (samples[i - 1].metrics || {}).rss || 0; + const b = (samples[i].metrics || {}).rss || 0; + const delta = b - a; + if (delta > 40 * 1024 * 1024 && (!best || delta > best.delta)) { + best = { index: i, delta, turn: samples[i].turnNumber || 1, kind: "memory", severity: delta > 80 * 1024 * 1024 ? "critical" : "warning" }; + } + } + return best; +} + +function demoRaw() { + const t0 = Date.now() - 9 * 60000; + const files = [ + "app/api/invoices/[id]/route.ts", "src/store/invoices.ts", "src/types.ts", + "app/api/invoices/route.ts", "src/autopsy/engine.ts", "src/components/GhostCity.tsx", + "app/playground/page.tsx", "src/components/Dashboard.tsx", "src/collector/opencode.ts", + "src/runner/metrics.ts", "app/globals.css", "README.md", + ]; + const prompts = [ + "add the invoices API — list plus read by id", + "wire the read route into the in-memory store", + "ship the playground shell with the tape rail", + "optimize the store lookup — cache by owner", + "tune the city palette to violet and cyan", + "add runtime probes to the recorder", + "write the autopsy verdict card", + "document the three minute demo path", + ]; + const turns = prompts.map((p, i) => ({ + index: i + 1, id: "demo-" + i, estimated: false, agent: "opencode", prompt: p, + startedAt: new Date(t0 + i * 52000).toISOString(), + endedAt: new Date(t0 + i * 52000 + 40000).toISOString(), + files: files.slice(0, 3 + ((i * 2) % 7)), + })); + const checkpoints = turns.map((t, i) => { + const touched = files.slice(i % 3, (i % 3) + 4); + const churn = {}; + touched.forEach((f, j) => { churn[f] = 8 + ((i * 7 + j * 13) % 42); }); + return { ts: t.endedAt, turnNumber: t.index, sha: "demo" + String(i).padStart(4, "0"), files: touched, churn }; + }); + const samples = []; + for (let i = 0; i < 28; i += 1) { + const turn = clamp(1 + Math.floor(i / 3.5), 1, 8); + const spike = turn >= 4 && turn <= 5 ? 52 * 1024 * 1024 : 0; + samples.push({ + ts: new Date(t0 + i * 15000).toISOString(), turnNumber: turn, + metrics: { rss: 120 * 1024 * 1024 + i * 1.4 * 1024 * 1024 + spike, cpu: 18 + Math.sin(i / 2) * 6 + (turn === 4 ? 38 : 0), heap: 64 * 1024 * 1024, latencyMs: null, errors: 0, uptime: i * 15 }, + probeFailed: false, + }); + } + return { id: "demo-session", repo: "ghostinthecodebase", head: "8c0073a9f2", app: { baseUrl: "http://localhost:3000" }, turns, checkpoints, samples, demo: true }; +} + +// ── render ─────────────────────────────────────────────────────────────────── +function apply(raw) { + const model = normalize(raw); + state.raw = raw; + state.id = model.id; state.repo = model.repo; state.head = model.head; state.agent = model.agent; + state.turns = model.turns; state.towers = model.towers; state.samples = model.samples; + state.anomaly = model.anomaly; state.blamedPath = model.blamedPath; + if (state.first) { state.active = Math.max(0, state.turns.length - 1); state.first = false; } + state.active = clamp(state.active, 0, Math.max(0, state.turns.length - 1)); + if (state.selected && !state.towers.some((t) => t.path === state.selected)) state.selected = null; + renderAll(); + const sig = state.towers.map((t) => t.path + ":" + t.h + ":" + (t.path === state.blamedPath ? 1 : 0)).join("|") + "#" + (state.demo ? "demo" : "live"); + if (sig !== state.sig) { state.sig = sig; buildScene(); } + else updateSelection(); +} + +function renderAll() { + renderTop(); renderRail(); renderAutopsy(); renderDock(); renderStage(); updateSelection(); +} + +function renderTop() { + $("pillSession").textContent = state.id; + $("pillAgent").textContent = state.agent; + $("pillRepo").textContent = state.repo; + const live = state.demo ? false : !state.raw || state.raw.status === "recording"; + $("liveChip").setAttribute("data-on", live ? "true" : "false"); + $("liveText").textContent = state.demo ? "Demo" : live ? "Live" : "Replay"; + $("railRec").setAttribute("data-on", live ? "true" : "false"); + $("dockRec").setAttribute("data-on", live || state.playing ? "true" : "false"); + $("btnDemo").setAttribute("data-on", state.demo ? "true" : "false"); + $("btnDemo").textContent = state.demo ? "Live" : "Demo"; + $("railAgent").textContent = state.agent; + $("railSid").textContent = state.id === "—" ? "—" : String(state.id).slice(0, 8); +} + +function renderRail() { + $("railCount").textContent = state.turns.length + " turns"; + const anomalyTurn = state.anomaly ? state.anomaly.turn : null; + $("tape").innerHTML = state.turns.map((t, i) => { + const active = i === state.active; + const hot = anomalyTurn != null && t.n === anomalyTurn; + const node = hot ? GHOST(15) : t.role === "assistant" ? ICON.robot : ICON.user; + const badge = hot + ? '' + ICON.warning.replace('width="14" height="14"', 'width="12" height="12"') + "regression" + : t.role === "assistant" ? 'AI' : ""; + const files = active && t.files.length + ? '' + t.files.map((f) => "" + esc(f) + "").join("") + "" + : ""; + return '
  • ' + + '
  • "; + }).join("") || '
    ' + GHOST(26, "ghost-mark--glow") + "

    No turns yet — talk to your agent; the tape fills live.

    "; +} + +function renderStage() { + $("stageTag").innerHTML = ICON.cube + "code city · " + state.turns.length + " turns"; + const empty = !state.towers.length; + $("cityEmpty").style.display = empty ? "flex" : "none"; + if (empty) { + $("cityEmpty").innerHTML = GHOST(34, "ghost-mark--glow") + + "

    " + (state.demo ? "building the demo skyline" : "the city is empty") + "

    " + + "

    " + (state.demo + ? "demo fixture — no files from your machine are shown." + : 'each file your agent touches raises a tower. run your agent in this repo and the recorder lays towers from real checkpoints.
    ') + "

    "; + const demo = $("emptyDemo"); + if (demo) demo.onclick = () => $("btnDemo").click(); + } + [...document.querySelectorAll("[data-preset]")].forEach((b) => b.setAttribute("data-active", b.dataset.preset === state.preset ? "true" : "false")); +} + +function chartSvg(samples) { + if (!samples.length) { + return '
    ' + GHOST(24, "ghost-mark--glow") + "

    0 samples yet — point the recorder at your app (--app-url) so health samples arrive.

    "; + } + const w = 320, h = 92, pad = 8; + const rss = samples.map((s) => (s.metrics || {}).rss || 0); + const cpu = samples.map((s) => (s.metrics || {}).cpu || 0); + const maxR = Math.max(...rss, 0); + const maxC = Math.max(...cpu, 0); + if (maxR <= 0 && maxC <= 0) { + return '
    ' + GHOST(24, "ghost-mark--glow") + "

    the probe answered, but returned no rss/cpu numbers yet — check /api/health on your app.

    "; + } + const scaleR = Math.max(maxR, 1); + const scaleC = Math.max(maxC, 1); + const x = (i) => pad + (i / Math.max(1, samples.length - 1)) * (w - pad * 2); + const yR = (v) => h - pad - (v / scaleR) * (h - pad * 2); + const yC = (v) => h - pad - (v / scaleC) * (h - pad * 2); + const line = (arr, y) => arr.map((v, i) => (i ? "L" : "M") + x(i).toFixed(1) + " " + y(v).toFixed(1)).join(" "); + const threshold = yR(scaleR * 0.82); + const activeN = state.turns[state.active] ? state.turns[state.active].n : 0; + let activeIdx = -1; + samples.forEach((s, i) => { if ((s.turnNumber || 0) <= activeN) activeIdx = i; }); + return '
    ' + + 'rss' + + 'cpu' + + 'threshold' + + '' + samples.length + " samples
    " + + '' + + '' + + (activeIdx >= 0 ? '' : "") + + '' + + '' + + '' + Math.round(scaleR / 1024 / 1024) + " MB peak" + + '' + Math.round(scaleC) + "% cpu" + + ""; +} + +function renderAutopsy() { + const turn = state.turns[state.active] || null; + const anomaly = state.anomaly; + const healed = false; + const stateLabel = anomaly ? "regression" : turn && state.samples.length ? "no anomaly" : "waiting"; + const stateIcon = anomaly ? ICON.warning : turn && state.samples.length ? ICON.check : ICON.warning; + let html = '
    Autopsy' + + '' + stateIcon + stateLabel + "
    "; + + if (!turn) { + html += '
    —
    ' + + 'recording — no turn data yet' + + 'the tape is armed; the first burst of file edits opens turn 1.' + + "
    "; + } else if (anomaly) { + const blame = state.turns.find((t) => t.n === anomaly.turn); + const file = state.blamedPath || (blame && blame.files[0]) || "files captured per checkpoint"; + const patch = blame && blame.sha + ? (state.demo + ? '" + : '' + ICON.file + "View patch") + : ""; + html += '
    ' + + 'Blamed turn' + anomaly.kind + " over threshold
    " + + '
    t' + anomaly.turn + "
    " + + '' + esc(file) + "" + + '' + anomaly.kind + " · " + anomaly.severity + " · turn " + anomaly.turn + "
    " + + '
    " + patch + "
    "; + } else if (!state.samples.length) { + html += '
    —
    ' + + 'runtime monitor waiting' + + '0 samples yet — the engine cannot judge curves it has not seen. point the recorder at your app (--app-url) so health samples arrive.' + + "
    "; + } else { + html += '
    —
    ' + + 'no anomaly detected' + + 'the recorded samples stayed inside every threshold so far' + + "
    "; + } + + if (state.selected) { + const tower = state.towers.find((t) => t.path === state.selected); + const inTurn = turn && turn.files.indexOf(state.selected) >= 0; + html += '
    ' + + '' + ICON.file + esc(state.selected) + "" + + '
    " + + '
    turn ' + (inTurn ? turn.n : "ambient") + "" + + "" + (tower ? tower.owner : "module") + "" + + "" + (tower && tower.loc ? tower.loc + " lines" : "no churn") + "
    " + + (inTurn ? "" : "

    No file changes for this tower in the selected turn.

    ") + "
    "; + } + + if (turn) { + html += '
    Turn t' + turn.n + "" + + '' + (turn.role === "assistant" ? ICON.robot : ICON.user) + turn.role + "
    " + + '

    ' + esc(turn.text) + "

    " + + (turn.files.length ? '
    ' + turn.files.map((f) => "" + esc(f) + "").join("") + "
    " : "") + "
    "; + html += '
    Code diff' + + '' + (turn.sha ? "loading…" : "no patch") + "
    " + + (turn.sha ? '
    checkpoint ' + esc(String(turn.sha).slice(0, 10)) + '
    loading patch…
    ' : '

    No patch for this turn — the recorder only stores diffs when git shadow checkpoints exist.

    ') + "
    "; + } + + html += '
    Runtime' + state.samples.length + " samples
    " + chartSvg(state.samples) + "
    "; + $("autopsy").innerHTML = html; + + const jump = $("jumpBlame"); + if (jump) jump.onclick = () => { const i = state.turns.findIndex((t) => t.n === (state.anomaly ? state.anomaly.turn : 0)); if (i >= 0) selectTurn(i); state.preset = "blame"; renderAll(); }; + const clear = $("clearSel"); + if (clear) clear.onclick = () => { state.selected = null; renderAll(); }; + const viewPatch = $("viewPatch"); + if (viewPatch) viewPatch.onclick = () => { + const d = document.querySelector(".pg-details"); + if (d) { d.open = true; d.scrollIntoView({ block: "nearest" }); } + }; + if (turn && turn.sha) loadDiff(turn.sha); +} + +function demoDiff(sha) { + const cp = ((state.raw && state.raw.checkpoints) || []).find((c) => c.sha === sha); + if (!cp) return ""; + let out = ""; + for (const f of cp.files || []) { + out += "+++ b/" + f + "\n@@ -1,6 +1,8 @@\n"; + out += "- const rows = listInvoices();\n- return rows.filter((row) => row.owner === owner);\n"; + out += "+ const rows = cache.get(owner) ?? listInvoices(owner);\n+ cache.set(owner, rows);\n+ return rows;\n"; + out += " // checkpoint " + cp.sha.slice(0, 10) + " · turn " + cp.turnNumber + "\n"; + } + return out; +} + +async function loadDiff(sha) { + const seq = ++state.seq; + let text = state.diffCache.get(sha); + if (text == null) { + if (state.demo) text = demoDiff(sha); + else { + try { + const res = await fetch("/diff/" + encodeURIComponent(sha)); + text = res.ok ? await res.text() : ""; + } catch (e) { text = ""; } + } + state.diffCache.set(sha, text); + } + if (seq !== state.seq) return; + const body = $("diffBody"); + const count = $("diffCount"); + if (!body) return; + const lines = text.split(/\r?\n/).filter((l) => l && !l.startsWith("diff --git") && !l.startsWith("index ")); + if (count) count.textContent = lines.length ? lines.length + " lines" : "empty"; + body.innerHTML = "
    " + lines.slice(0, 220).map((l) => {
    +    const cls = l.startsWith("+++") || l.startsWith("---") ? "meta" : l.startsWith("+") ? "add" : l.startsWith("-") ? "del" : "meta";
    +    return '
    ' + esc(l) + "
    "; + }).join("") + "
    "; +} + +function renderDock() { + const last = Math.max(1, state.turns.length - 1); + const progress = state.turns.length ? (state.active / last) * 100 : 0; + $("tc").textContent = timecode(state.turns[state.active]); + $("dockTurn").textContent = "t" + (state.turns.length ? state.active + 1 : 0) + " / " + state.turns.length; + $("play").innerHTML = state.playing ? ICON.pause : ICON.play; + $("play").setAttribute("aria-label", state.playing ? "Pause replay" : "Play replay"); + $("speed").textContent = state.speed + "x"; + + const anomalyTurn = state.anomaly ? state.anomaly.turn : null; + const ticks = state.turns.map((t, i) => + '' + ).join(""); + const anomaly = anomalyTurn != null + ? '' + : ""; + $("track").innerHTML = '
    ' + + '
    ' + ticks + anomaly + + '' + GHOST(16) + ""; +} + +// ── interaction ────────────────────────────────────────────────────────────── +function selectTurn(i) { + if (!state.turns.length) return; + state.playing = false; + state.active = clamp(i, 0, state.turns.length - 1); + state.selected = null; + renderAll(); + const turn = state.turns[state.active]; + const tower = turn && turn.files.length ? state.towers.find((t) => t.path === turn.files[0]) : null; + if (tower) flyTo(tower); +} + +function togglePlay() { + if (!state.turns.length) return; + state.playing = !state.playing; + if (state.playing && state.active >= state.turns.length - 1) state.active = 0; + renderAll(); + schedule(); +} + +function schedule() { + clearTimeout(timer); + if (!state.playing) return; + timer = setTimeout(() => { + if (!state.playing) return; + if (state.active >= state.turns.length - 1) { state.playing = false; renderAll(); return; } + state.active += 1; + renderAll(); + const turn = state.turns[state.active]; + const tower = turn && turn.files.length ? state.towers.find((t) => t.path === turn.files[0]) : null; + if (tower) flyTo(tower); + schedule(); + }, 1800 / state.speed); +} + +function bind() { + $("tape").addEventListener("click", (e) => { + const b = e.target.closest(".pg-turn"); + if (b) selectTurn(Number(b.dataset.i)); + }); + $("track").addEventListener("click", (e) => { + const b = e.target.closest(".pg-dock__tick"); + if (b) selectTurn(Number(b.dataset.i)); + }); + $("prev").onclick = () => { state.playing = false; selectTurn(state.active - 1); }; + $("next").onclick = () => { state.playing = false; selectTurn(state.active + 1); }; + $("play").onclick = togglePlay; + $("speed").onclick = () => { state.speed = state.speed === 0.5 ? 1 : state.speed === 1 ? 2 : 0.5; renderAll(); }; + $("btnReload").onclick = () => { state.first = true; tick(); }; + $("btnDemo").onclick = () => { + state.demo = !state.demo; + state.playing = false; + if (state.demo) { apply(demoRaw()); } + else { state.first = true; state.raw = null; state.rawSig = null; tick(); } + renderAll(); + }; + document.querySelectorAll("[data-tab]").forEach((b) => { + b.onclick = () => { + $("shell").setAttribute("data-mobile", b.dataset.tab); + document.querySelectorAll("[data-tab]").forEach((x) => x.setAttribute("data-active", x.dataset.tab === b.dataset.tab ? "true" : "false")); + }; + }); + document.querySelectorAll("[data-preset]").forEach((b) => { + b.onclick = () => { + state.preset = b.dataset.preset; + if (state.preset === "blame" && state.anomaly) { + const i = state.turns.findIndex((t) => t.n === state.anomaly.turn); + if (i >= 0) { state.active = i; state.selected = state.blamedPath; } + } + renderAll(); + if (state.preset === "blame") { const t = state.towers.find((x) => x.path === state.selected) || (state.anomaly && state.towers.find((x) => x.path === state.blamedPath)); if (t) flyTo(t, true); } + else flyTo({ x: 0, z: 0, h: 4 }, false); + }; + }); + addEventListener("keydown", (e) => { + const tag = (e.target && e.target.tagName) || ""; + if (["INPUT", "TEXTAREA", "SELECT"].indexOf(tag) >= 0) return; + if (e.code === "Space") { e.preventDefault(); togglePlay(); } + else if (e.key === "ArrowRight") { e.preventDefault(); state.playing = false; selectTurn(state.active + 1); } + else if (e.key === "ArrowLeft") { e.preventDefault(); state.playing = false; selectTurn(state.active - 1); } + }); +} + +// ── three.js ───────────────────────────────────────────────────────────────── +let THREE = null, OrbitControls = null, renderer = null, scene = null, camera = null, controls = null; +let group = null, towerMeshes = [], beam = null, raycaster = null, pointer = null; +let threeReady = false, winTex = null, auraTex = null, flying = null; +const clock = { t: 0 }; + +function makeWindowTexture() { + const c = document.createElement("canvas"); + c.width = 64; c.height = 128; + const g = c.getContext("2d"); + g.fillStyle = "#000"; g.fillRect(0, 0, 64, 128); + for (let y = 6; y < 122; y += 11) { + for (let x = 5; x < 60; x += 9) { + if (Math.random() < 0.55) { + g.fillStyle = "rgba(200,190,255," + (0.35 + Math.random() * 0.6).toFixed(2) + ")"; + g.fillRect(x, y, 5, 5); + } + } + } + const tex = new THREE.CanvasTexture(c); + tex.wrapS = tex.wrapT = THREE.RepeatWrapping; + return tex; +} + +function makeAuraTexture() { + const c = document.createElement("canvas"); + c.width = c.height = 128; + const g = c.getContext("2d"); + const grd = g.createRadialGradient(64, 64, 2, 64, 64, 62); + grd.addColorStop(0, "rgba(255,122,107,.85)"); + grd.addColorStop(0.5, "rgba(255,122,107,.24)"); + grd.addColorStop(1, "rgba(255,122,107,0)"); + g.fillStyle = grd; g.fillRect(0, 0, 128, 128); + return new THREE.CanvasTexture(c); +} + +async function initThree() { + THREE = await import("three"); + ({ OrbitControls } = await import("three/addons/controls/OrbitControls.js")); + const canvas = $("gl"); + renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "high-performance" }); + renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2)); + renderer.setClearColor(0x000000, 0); + scene = new THREE.Scene(); + scene.fog = new THREE.FogExp2(0x05060a, 0.011); + camera = new THREE.PerspectiveCamera(38, 1, 0.1, 240); + camera.position.set(22, 18, 32); + controls = new OrbitControls(camera, canvas); + controls.enableDamping = true; controls.dampingFactor = 0.07; + controls.autoRotate = true; controls.autoRotateSpeed = 0.35; + controls.minDistance = 7; controls.maxDistance = 95; + controls.maxPolarAngle = Math.PI / 2.05; + controls.target.set(0, 2, 0); + + scene.add(new THREE.AmbientLight(0x8f96c8, 0.9)); + const key = new THREE.PointLight(0xcbd5ff, 24, 80, 2); key.position.set(0, 20, 12); scene.add(key); + const violet = new THREE.PointLight(0x8b7cff, 18, 70, 2); violet.position.set(-18, 9, -12); scene.add(violet); + const cyan = new THREE.PointLight(0x67e8f9, 10, 60, 2); cyan.position.set(16, 6, -16); scene.add(cyan); + + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(220, 220), + new THREE.MeshStandardMaterial({ color: 0x06070d, roughness: 0.95, metalness: 0.15 }) + ); + ground.rotation.x = -Math.PI / 2; ground.position.y = -0.03; scene.add(ground); + const grid = new THREE.GridHelper(140, 92, 0x3d2f7a, 0x1b1938); + grid.material.transparent = true; grid.material.opacity = 0.55; grid.position.y = 0.002; scene.add(grid); + + const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: makeAuraTexture(), color: 0x8b7cff, transparent: true, opacity: 0.22, depthWrite: false })); + glow.scale.set(70, 70, 1); glow.position.y = 0.05; scene.add(glow); + + group = new THREE.Group(); scene.add(group); + raycaster = new THREE.Raycaster(); pointer = new THREE.Vector2(); + winTex = makeWindowTexture(); auraTex = makeAuraTexture(); + threeReady = true; + + const resize = () => { + const box = canvas.parentElement; + const w = Math.max(1, box.clientWidth), h = Math.max(1, box.clientHeight); + renderer.setSize(w, h, false); + camera.aspect = w / h; camera.updateProjectionMatrix(); + }; + resize(); + if (window.ResizeObserver) new ResizeObserver(resize).observe(canvas.parentElement); + addEventListener("resize", resize); + + let down = null; + canvas.addEventListener("pointerdown", (e) => { down = { x: e.clientX, y: e.clientY }; }); + canvas.addEventListener("pointermove", (e) => { + const hit = pick(e); + const tip = $("tip"); + if (hit) { + const t = hit.userData.tower; + $("city").setAttribute("data-hovered", "true"); + tip.style.display = "block"; + tip.style.left = Math.min(e.clientX + 14, innerWidth - 300) + "px"; + tip.style.top = (e.clientY + 14) + "px"; + tip.innerHTML = '' + esc(t.path) + "" + + 'turn t' + t.turn + " · " + (t.git ? "churn " + t.loc + " lines" : "touched · no patch") + "" + + (t.path === state.blamedPath ? 'blamed · anomaly' : ""); + } else { + $("city").setAttribute("data-hovered", "false"); + tip.style.display = "none"; + } + }); + canvas.addEventListener("pointerleave", () => { $("tip").style.display = "none"; }); + canvas.addEventListener("click", (e) => { + if (down && Math.hypot(e.clientX - down.x, e.clientY - down.y) > 5) return; + const hit = pick(e); + if (hit) selectTower(hit.userData.tower); + else { state.selected = null; renderAll(); } + }); + + animate(); + buildScene(); + if (!state.camInit && state.towers.length) { + state.camInit = true; + flyTo({ x: 0, z: 0, h: 4 }, false); + } +} + +function overviewDist() { + const ext = state.towers.length + ? Math.max(...state.towers.map((t) => Math.max(Math.abs(t.x), Math.abs(t.z)))) + : 12; + return clamp(ext * 2.4 + 16, 36, 86); +} + +function pick(e) { + if (!threeReady) return null; + const r = renderer.domElement.getBoundingClientRect(); + pointer.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1); + raycaster.setFromCamera(pointer, camera); + return raycaster.intersectObjects(towerMeshes, false)[0]?.object || null; +} + +function disposeGroup() { + group.traverse((o) => { + if (o.geometry) o.geometry.dispose(); + if (o.material) { + if (o.material.emissiveMap && o.material.emissiveMap !== winTex) o.material.emissiveMap.dispose(); + o.material.dispose(); + } + }); + group.clear(); + towerMeshes = []; + beam = null; +} + +function buildScene() { + if (!threeReady) return; + disposeGroup(); + for (const t of state.towers) addTower(t); + if (state.demo) addAmbient(); + updateSelection(); + $("cityEmpty").style.display = state.towers.length ? "none" : "flex"; +} + +function addTower(t) { + const hot = t.path === state.blamedPath; + const geo = new THREE.BoxGeometry(t.w, t.h, t.d); + geo.translate(0, t.h / 2, 0); + const tex = winTex.clone(); + tex.needsUpdate = true; + tex.repeat.set(Math.max(1, Math.round(t.w * 2)), Math.max(2, Math.round(t.h * 1.1))); + const mat = new THREE.MeshStandardMaterial({ + color: new THREE.Color().setHSL(hot ? 0.015 : 0.7, hot ? 0.6 : 0.45, hot ? 0.42 : 0.32), + emissive: new THREE.Color().setHSL(hot ? 0.015 : 0.7, 0.85, hot ? 0.3 : 0.16), + emissiveMap: tex, + emissiveIntensity: 1, + roughness: 0.38, + metalness: 0.4, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.set(t.x, 0, t.z); + mesh.userData.tower = t; + const edges = new THREE.LineSegments( + new THREE.EdgesGeometry(geo), + new THREE.LineBasicMaterial({ color: hot ? 0xff7a6b : 0x67e8f9, transparent: true, opacity: hot ? 0.55 : 0.22 }) + ); + mesh.add(edges); + if (hot) { + const aura = new THREE.Sprite(new THREE.SpriteMaterial({ map: auraTex, color: 0xff7a6b, transparent: true, opacity: 0.5, depthWrite: false })); + aura.scale.set(4, 4, 1); aura.position.set(0, t.h * 0.55, 0); mesh.add(aura); + } + group.add(mesh); + towerMeshes.push(mesh); + t._mesh = mesh; +} + +function addAmbient() { + const spacing = 3.05, cols = 12, rows = 10; + let seed = 7; + const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; }; + for (let i = 0; i < 46; i += 1) { + const col = Math.floor(rnd() * cols), row = Math.floor(rnd() * rows); + const x = (col - (cols - 1) / 2) * spacing + (rnd() - 0.5) * 1.2; + const z = (row - (rows - 1) / 2) * spacing + (rnd() - 0.5) * 1.2; + if (state.towers.some((t) => Math.hypot(t.x - x, t.z - z) < 1.6)) continue; + const h = 0.8 + rnd() * 3.4; + const geo = new THREE.BoxGeometry(1 + rnd() * 1.2, h, 1 + rnd() * 1.2); + geo.translate(0, h / 2, 0); + const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ + color: 0x121427, emissive: 0x2a2450, emissiveIntensity: 0.5, roughness: 0.8, metalness: 0.2, + })); + mesh.position.set(x, 0, z); + mesh.userData.ambient = true; + group.add(mesh); + } +} + +function selectTower(t) { + state.selected = t.path; + state.preset = "blame"; + if (innerWidth <= 900) { + $("shell").setAttribute("data-mobile", "autopsy"); + document.querySelectorAll("[data-tab]").forEach((x) => x.setAttribute("data-active", x.dataset.tab === "autopsy" ? "true" : "false")); + } + renderAll(); + flyTo(t, true); +} + +function updateSelection() { + if (!threeReady) return; + for (const mesh of towerMeshes) { + const t = mesh.userData.tower; + const on = t.path === state.selected; + const hot = t.path === state.blamedPath; + mesh.material.emissiveIntensity = on ? 2.4 : hot ? 1.6 : 1; + const edge = mesh.children[0]; + if (edge && edge.material) edge.material.opacity = on ? 0.85 : hot ? 0.55 : 0.22; + } + if (beam) { group.remove(beam); beam.geometry.dispose(); beam.material.dispose(); beam = null; } + const sel = state.towers.find((t) => t.path === state.selected); + if (sel && sel._mesh) { + const h = 26; + beam = new THREE.Mesh( + new THREE.CylinderGeometry(0.05, 0.05, h, 6, 1, true), + new THREE.MeshBasicMaterial({ color: 0xb6a8ff, transparent: true, opacity: 0.3, depthWrite: false }) + ); + beam.position.set(sel.x, h / 2, sel.z); + group.add(beam); + } +} + +function flyTo(t, close) { + if (!threeReady) return; + const target = new THREE.Vector3(t.x || 0, close ? Math.min(6, (t.h || 4) * 0.6) : 2, t.z || 0); + const dist = close ? 14 : overviewDist(); + const pos = new THREE.Vector3((t.x || 0) + dist * 0.55, dist * 0.62, (t.z || 0) + dist * 0.8); + const fromPos = camera.position.clone(); + const fromTarget = controls.target.clone(); + const start = performance.now(); + controls.autoRotate = false; + flying = () => { + const k = Math.min(1, (performance.now() - start) / 700); + const e = 1 - Math.pow(1 - k, 3); + camera.position.lerpVectors(fromPos, pos, e); + controls.target.lerpVectors(fromTarget, target, e); + if (k >= 1) { flying = null; controls.autoRotate = state.preset === "overview"; } + }; +} + +function animate() { + requestAnimationFrame(animate); + if (!threeReady) return; + clock.t += 0.016; + if (flying) flying(); + controls.update(); + if (beam) beam.material.opacity = 0.18 + Math.sin(clock.t * 2.2) * 0.1; + for (const mesh of towerMeshes) { + if (mesh.userData.tower && mesh.userData.tower.path === state.blamedPath) { + const pulse = 0.9 + Math.sin(clock.t * 3.2) * 0.35; + mesh.material.emissiveIntensity = Math.max(mesh.material.emissiveIntensity, pulse * 1.3); + } + } + renderer.render(scene, camera); +} + +// ── live loop ──────────────────────────────────────────────────────────────── +async function tick() { + if (state.demo) return; + try { + const res = await fetch("/session", { cache: "no-store" }); + if (!res.ok) throw new Error("bad status"); + const raw = await res.json(); + const sig = JSON.stringify(raw); + if (sig !== state.rawSig) { state.rawSig = sig; apply(raw); } + } catch (e) { + $("liveChip").setAttribute("data-on", "false"); + $("liveText").textContent = "reconnecting"; + } +} + +async function boot() { + bind(); + renderAll(); + const params = new URLSearchParams(location.search); + if (params.get("demo") === "1") { state.demo = true; apply(demoRaw()); } + try { + await initThree(); + } catch (e) { + threeReady = false; + $("cityEmpty").style.display = "flex"; + $("cityEmpty").innerHTML = GHOST(34, "ghost-mark--glow") + + "

    3D engine unavailable

    three.js could not load from the CDN (offline?). the zero-dependency 2D city still works: /city2d

    "; + } + tick(); + setInterval(tick, 1600); +} + +boot(); diff --git a/packages/ghost/lib/city.css b/packages/ghost/lib/city.css new file mode 100644 index 0000000..eb4f725 --- /dev/null +++ b/packages/ghost/lib/city.css @@ -0,0 +1,1584 @@ +:root{ + --ink-base:#05060a;--ink-1:#0b0d14;--ink-2:#11141d;--ink-3:#181c28; + --fg:#edeff5;--fg-2:#a8b0c4;--fg-3:#7f889c; + --line:rgba(255,255,255,.09);--line-strong:rgba(255,255,255,.16); + --violet:#8b7cff;--violet-soft:#b6a8ff;--cyan:#67e8f9;--emerald:#5eead4;--coral:#ff7a6b; + --font-sans:"Inter","Segoe UI",system-ui,-apple-system,sans-serif; + --font-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; + --font-display-stack:var(--font-sans); + --font-sans-stack:var(--font-sans); + --font-mono-stack:var(--font-mono); + --ease-out:cubic-bezier(.16,1,.3,1);--ease-in-out:cubic-bezier(.65,0,.35,1); + color-scheme:dark; +} +*,*::before,*::after{box-sizing:border-box} +html,body{height:100%} +body{margin:0;min-width:320px;background:var(--ink-base);color:var(--fg);font-family:var(--font-sans-stack);font-size:14px;line-height:1.6;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale} +h1,h2,h3,h4,p,figure{margin:0} +ul,ol{margin:0;padding:0;list-style:none} +button,input,textarea,select{font:inherit;color:inherit} +button{background:none;border:0;cursor:pointer} +a{color:inherit;text-decoration:none} +canvas{display:block;max-width:100%} +.ghost-mark--glow{filter:drop-shadow(0 0 16px rgba(139,124,255,.55))} +.kbd{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;padding:0 6px;border:1px solid var(--line-strong);border-radius:5px;background:rgba(255,255,255,.035);color:var(--fg-2);font-family:var(--font-mono-stack);font-size:11px;line-height:1;box-shadow:inset 0 -1px 0 rgba(255,255,255,.08)} +.diff{font-family:var(--font-mono-stack);font-size:12px;background:#0d1117;border:1px solid var(--line);border-radius:8px;overflow:auto} +.diff pre{margin:0;padding:2px 10px;white-space:pre;font-family:inherit} +.diff .add{background:rgba(63,185,80,.12);color:#7ee787} +.diff .del{background:rgba(255,77,79,.12);color:#ff7b72} +.diff .meta{color:var(--fg-3)} +/* ========================================================================== + Playground shell + ========================================================================== */ + +.dashboard-page { + --pg-top: 64px; + --pg-dock: 78px; + --pg-rail: 300px; + --pg-panel: 380px; + --pg-gap: 12px; + --bg: var(--ink-base); + --bg-panel: var(--ink-1); + --bg-elev: var(--ink-2); + --border: var(--line); + --text: var(--fg); + --text-dim: var(--fg-2); + --accent: var(--violet); + background: var(--ink-base); + color: var(--fg); + font-family: var(--font-sans-stack); + font-size: 14px; +} + +.dashboard-page .app { + height: 100dvh; + min-height: 0; + overflow: hidden; + background: var(--ink-base); +} + +.pg-shell { + display: flex; + flex-direction: column; + height: 100dvh; + min-height: 0; + overflow: hidden; + background: + radial-gradient(80% 50% at 70% 0%, rgba(139, 124, 255, 0.09), transparent 65%), + var(--ink-base); +} + +.pg-label { + font-family: var(--font-mono-stack); + font-size: 11px; + font-weight: 400; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-3); +} + +/* ========================================================================== + Top bar + ========================================================================== */ + +.pg-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + height: var(--pg-top); + flex: none; + padding: 0 16px; + border-bottom: 1px solid var(--line); + background: rgba(5, 6, 10, 0.78); + backdrop-filter: blur(18px); +} + +.pg-top__brand { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.pg-top__title { + display: flex; + align-items: baseline; + gap: 7px; + min-width: 0; +} + +.pg-top__title strong { + font-family: var(--font-display-stack); + font-size: 20px; + font-weight: 400; + letter-spacing: -0.01em; + line-height: 1; +} + +.pg-top__title span { + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-3); + white-space: nowrap; +} + +.pg-pills { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + overflow: hidden; +} + +.pg-pill { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 9px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--fg-2); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.02em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pg-top__actions { + display: flex; + align-items: center; + gap: 8px; + flex: none; +} + +.pg-live { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.pg-live i, +.pg-rec i { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--fg-3); + flex: none; +} + +.pg-live[data-on="true"] { + color: var(--coral); +} + +.pg-live[data-on="true"] i, +.pg-rec[data-on="true"] i { + background: var(--coral); + box-shadow: 0 0 10px rgba(255, 122, 107, 0.9); + animation: pgRec 1.5s ease-in-out infinite; +} + +@keyframes pgRec { + 50% { + opacity: 0.25; + } +} + +.pg-pill-btn { + display: inline-flex; + align-items: center; + gap: 7px; + height: 32px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.025); + color: var(--fg-2); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.02em; + transition: + color 0.25s var(--ease-out), + border-color 0.25s var(--ease-out), + background-color 0.25s var(--ease-out); +} + +.pg-pill-btn:hover { + color: var(--fg); + border-color: var(--line-strong); + background: rgba(255, 255, 255, 0.055); +} + +.pg-icon-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--fg-2); + transition: + color 0.25s, + border-color 0.25s, + background-color 0.25s; +} + +.pg-icon-link:hover { + color: var(--fg); + border-color: var(--line-strong); + background: rgba(255, 255, 255, 0.045); +} + +/* ========================================================================== + Body / rail + ========================================================================== */ + +.pg-body { + display: grid; + grid-template-columns: var(--pg-rail) minmax(0, 1fr); + gap: var(--pg-gap); + min-height: 0; + flex: 1; + padding: var(--pg-gap); +} + +.pg-rail-wrap { + min-width: 0; + min-height: 0; +} + +.pg-rail { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow-y: auto; + padding: 16px 12px; + border: 1px solid var(--line); + border-radius: 16px; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.035), + rgba(255, 255, 255, 0.012) + ); + scrollbar-width: thin; +} + +.pg-rail__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 0 4px 14px; + border-bottom: 1px solid var(--line); +} + +.pg-rail__head h2 { + margin-top: 4px; + font-family: var(--font-sans-stack); + font-size: 19px; + font-weight: 500; + letter-spacing: -0.01em; + line-height: 1.1; +} + +.pg-rec { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.1em; +} + +.pg-rail__meta { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 10px 4px 14px; + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.02em; +} + +.pg-rail__meta span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pg-rail__list { + position: relative; + padding: 2px 0 8px; +} + +.pg-rail__item { + position: relative; + padding-left: 2px; + margin-bottom: 4px; +} + +.pg-rail__connector { + position: absolute; + left: 15px; + top: 28px; + bottom: -8px; + width: 1px; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.16), + rgba(255, 255, 255, 0.04) + ); +} + +.pg-rail__item:last-child .pg-rail__connector { + display: none; +} + +.pg-turn { + position: relative; + display: grid; + grid-template-columns: 27px minmax(0, 1fr); + gap: 10px; + width: 100%; + padding: 7px 8px; + border: 1px solid transparent; + border-radius: 10px; + text-align: left; + transition: + background-color 0.25s var(--ease-out), + border-color 0.25s var(--ease-out); +} + +.pg-turn:hover { + background: rgba(255, 255, 255, 0.035); +} + +.pg-turn[data-active="true"] { + border-color: rgba(139, 124, 255, 0.3); + background: linear-gradient( + 100deg, + rgba(139, 124, 255, 0.12), + rgba(139, 124, 255, 0.025) + ); +} + +.pg-turn[data-hot="true"] { + border-color: rgba(255, 122, 107, 0.35); + box-shadow: inset 2px 0 0 var(--coral); +} + +.pg-turn__node { + display: flex; + align-items: center; + justify-content: center; + width: 27px; + height: 27px; + border: 1px solid var(--line-strong); + border-radius: 50%; + background: var(--ink-1); + color: var(--fg-2); + z-index: 1; +} + +.pg-turn[data-active="true"] .pg-turn__node { + border-color: rgba(182, 168, 255, 0.6); + color: var(--violet-soft); + box-shadow: 0 0 16px rgba(139, 124, 255, 0.3); +} + +.pg-turn[data-hot="true"] .pg-turn__node { + border-color: rgba(255, 122, 107, 0.55); + color: var(--coral); + box-shadow: 0 0 18px rgba(255, 122, 107, 0.25); +} + +.pg-turn__body { + display: block; + min-width: 0; +} + +.pg-turn__top { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.pg-turn__index { + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.04em; +} + +.pg-turn[data-active="true"] .pg-turn__index { + color: var(--fg); +} + +.pg-turn__role { + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.pg-turn__badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 5px; + border: 1px solid rgba(139, 124, 255, 0.22); + border-radius: 4px; + color: var(--violet-soft); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.03em; + white-space: nowrap; +} + +.pg-turn__badge--hot { + border-color: rgba(255, 122, 107, 0.3); + color: #ffb0a7; +} + +.pg-turn__text { + display: -webkit-box; + overflow: hidden; + margin-top: 3px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + color: var(--fg-2); + font-size: 12.5px; + line-height: 1.45; +} + +.pg-turn[data-active="true"] .pg-turn__text { + -webkit-line-clamp: 5; + color: var(--fg); +} + +.pg-turn__files { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 8px; +} + +.pg-turn__files span, +.pg-files span { + padding: 3px 6px; + border: 1px solid var(--line); + border-radius: 4px; + color: var(--fg-2); + font-family: var(--font-mono-stack); + font-size: 11px; + line-height: 1.3; + overflow-wrap: anywhere; +} + +/* ========================================================================== + Stage + ========================================================================== */ + +.pg-work { + position: relative; + min-width: 0; + min-height: 0; +} + +.pg-stage { + position: absolute; + inset: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--ink-base); +} + +.pg-stage__sky { + position: absolute; + inset: 0; + background: + radial-gradient(70% 52% at 46% 56%, rgba(139, 124, 255, 0.18), transparent 70%), + radial-gradient(60% 42% at 75% 20%, rgba(103, 232, 249, 0.07), transparent 70%), + linear-gradient(180deg, #070810 0%, #05060a 64%, #04050a 100%); +} + +.pg-stage__sky::after { + content: ""; + position: absolute; + inset: 0; + background-image: + radial-gradient(1px 1px at 30px 40px, rgba(237, 239, 245, 0.65), transparent 60%), + radial-gradient(1px 1px at 130px 90px, rgba(182, 168, 255, 0.55), transparent 60%), + radial-gradient(1.3px 1.3px at 210px 30px, rgba(103, 232, 249, 0.55), transparent 60%); + background-size: 260px 180px; + opacity: 0.5; + mask-image: linear-gradient(180deg, #000, transparent 70%); +} + +.pg-stage__canvas { + position: absolute; + inset: 0; +} + +.pg-city { + position: relative; + width: 100%; + height: 100%; +} + +.pg-city canvas { + display: block; + cursor: grab; +} + +.pg-city[data-hovered="true"] canvas { + cursor: pointer; +} + +.pg-city__hint { + position: absolute; + left: 50%; + bottom: 12px; + transform: translateX(-50%); + padding: 5px 9px; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(5, 6, 10, 0.65); + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.04em; + white-space: nowrap; + backdrop-filter: blur(10px); + pointer-events: none; +} + +.pg-city--loading { + display: grid; + place-items: center; + min-height: 300px; +} + +.pg-stage__top { + position: absolute; + top: 12px; + left: 12px; + right: calc(var(--pg-panel) + 28px); + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + pointer-events: none; +} + +.pg-stage__tag { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 9px; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(5, 6, 10, 0.62); + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.06em; + backdrop-filter: blur(12px); +} + +.pg-presets { + display: flex; + gap: 4px; + pointer-events: auto; +} + +.pg-presets button { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 9px; + border: 1px solid var(--line); + border-radius: 7px; + background: rgba(5, 6, 10, 0.62); + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.02em; + backdrop-filter: blur(12px); + transition: + color 0.25s, + border-color 0.25s, + background-color 0.25s; +} + +.pg-presets button:hover { + color: var(--fg); + border-color: var(--line-strong); +} + +.pg-presets button[data-active="true"] { + color: var(--violet-soft); + border-color: rgba(139, 124, 255, 0.4); + background: rgba(139, 124, 255, 0.1); +} + +.pg-stage__legend { + position: absolute; + left: 12px; + bottom: 12px; + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + max-width: 310px; + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.03em; +} + +.pg-stage__legend span { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.pg-stage__legend span:nth-child(2) { + color: #ffb0a7; +} + +.pg-stage__legend span:nth-child(3) { + color: var(--violet-soft); +} + +.pg-city-tip { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 150px; + padding: 8px 10px; + border: 1px solid var(--line-strong); + border-radius: 8px; + background: rgba(11, 13, 20, 0.96); + box-shadow: 0 18px 50px -24px rgba(0, 0, 0, 0.9); + white-space: nowrap; +} + +.pg-city-tip__path { + max-width: 260px; + overflow: hidden; + color: var(--fg); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.02em; + text-overflow: ellipsis; +} + +.pg-city-tip__meta { + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.02em; +} + +.pg-city-tip__delta { + color: #ffb0a7; + font-family: var(--font-mono-stack); + font-size: 11px; +} + +/* ========================================================================== + Autopsy panel + ========================================================================== */ + +.pg-autopsy { + position: absolute; + top: 12px; + right: 12px; + bottom: 12px; + z-index: 4; + display: flex; + flex-direction: column; + width: var(--pg-panel); + overflow-y: auto; + padding: 14px; + border: 1px solid var(--line); + border-radius: 14px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.012)), + rgba(9, 11, 18, 0.9); + backdrop-filter: blur(22px); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.07), + 0 30px 80px -40px rgba(0, 0, 0, 0.9); + scrollbar-width: thin; +} + +.pg-autopsy__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-bottom: 10px; +} + +.pg-autopsy__state { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--coral); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.pg-autopsy__state[data-healed="true"] { + color: var(--emerald); +} + +.pg-verdict { + padding: 12px; + border: 1px solid rgba(255, 122, 107, 0.32); + border-radius: 12px; + background: + radial-gradient(100% 100% at 100% 0%, rgba(255, 122, 107, 0.13), transparent 60%), + rgba(255, 122, 107, 0.045); +} + +.pg-verdict[data-healed="true"] { + border-color: rgba(94, 234, 212, 0.32); + background: + radial-gradient(100% 100% at 100% 0%, rgba(94, 234, 212, 0.12), transparent 60%), + rgba(94, 234, 212, 0.04); +} + +.pg-verdict__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.pg-verdict__delta { + color: #ffb0a7; + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.pg-verdict[data-healed="true"] .pg-verdict__delta { + color: var(--emerald); +} + +.pg-verdict__main { + display: flex; + align-items: center; + gap: 12px; + margin-top: 8px; +} + +.pg-verdict__main strong { + font-family: var(--font-display-stack); + font-size: 38px; + font-weight: 400; + line-height: 1; + letter-spacing: -0.01em; + color: var(--coral); +} + +.pg-verdict[data-healed="true"] .pg-verdict__main strong { + color: var(--emerald); +} + +.pg-verdict__main div { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.pg-verdict__file { + color: var(--fg); + font-family: var(--font-mono-stack); + font-size: 11px; + overflow-wrap: anywhere; +} + +.pg-verdict__kind { + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; +} + +.pg-verdict__actions { + display: flex; + gap: 6px; + margin-top: 10px; +} + +.pg-verdict__actions button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + flex: 1; + height: 31px; + border: 1px solid var(--line-strong); + border-radius: 7px; + background: rgba(255, 255, 255, 0.04); + color: var(--fg); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.02em; + transition: + background-color 0.25s, + border-color 0.25s; +} + +.pg-verdict__actions button:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.28); +} + +.pg-verdict__actions button[data-healed="true"] { + color: var(--emerald); + border-color: rgba(94, 234, 212, 0.3); +} + +.pg-selected { + margin-top: 10px; + padding: 10px; + border: 1px solid rgba(139, 124, 255, 0.28); + border-radius: 10px; + background: rgba(139, 124, 255, 0.055); +} + +.pg-selected__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.pg-selected__file { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + color: var(--violet-soft); + font-family: var(--font-mono-stack); + font-size: 11px; + overflow-wrap: anywhere; +} + +.pg-selected__top button { + color: var(--fg-3); + flex: none; +} + +.pg-selected__top button:hover { + color: var(--fg); +} + +.pg-selected__meta { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + margin-top: 6px; + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; +} + +.pg-selected p { + margin-top: 6px; + color: var(--fg-2); + font-size: 12px; +} + +.pg-block { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--line); +} + +.pg-block__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.pg-block__role { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--fg-2); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.pg-block__count { + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; +} + +.pg-prompt { + color: var(--fg); + font-size: 13px; + line-height: 1.6; +} + +.pg-files { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 8px; +} + +.pg-details { + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + background: rgba(5, 6, 10, 0.4); +} + +.pg-details summary { + padding: 7px 9px; + border-bottom: 1px solid var(--line); + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; + cursor: pointer; +} + +.pg-details .diff { + margin: 0; + border: 0; + border-radius: 0; + background: transparent; + font-size: 11px; + max-height: 190px; + overflow: auto; +} + +.pg-details .diff .file { + display: none; +} + +.pg-details .diff pre { + padding: 6px 8px; +} + +.pg-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 92px; + padding: 16px; + border: 1px dashed var(--line); + border-radius: 8px; + color: var(--fg-3); +} + +.pg-empty p { + font-size: 12px; + text-align: center; +} + +.pg-fix { + padding: 9px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(5, 6, 10, 0.4); + color: var(--fg-2); + font-family: var(--font-mono-stack); + font-size: 11px; + line-height: 1.6; +} + +/* chart */ + +.pg-chart { + margin: 0 -4px; +} + +.pg-chart__legend { + display: flex; + flex-wrap: wrap; + gap: 5px 12px; + margin-bottom: 4px; + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; +} + +.pg-chart__legend span { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.pg-chart__swatch { + width: 12px; + height: 2px; + border-radius: 1px; +} + +.pg-chart__swatch--mem { + background: var(--coral); +} + +.pg-chart__swatch--lat { + background: var(--cyan); +} + +.pg-chart__swatch--threshold { + background: repeating-linear-gradient( + 90deg, + rgba(255, 122, 107, 0.7) 0 3px, + transparent 3px 5px + ); +} + +.pg-chart__svg { + display: block; + width: 100%; + height: auto; + overflow: visible; +} + +.pg-chart__axis { + fill: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 9px; + letter-spacing: 0.02em; +} + +.pg-chart__threshold { + fill: rgba(255, 176, 167, 0.85); + font-family: var(--font-mono-stack); + font-size: 9px; + letter-spacing: 0.02em; +} + +.pg-chart__active-label { + fill: var(--violet-soft); + font-family: var(--font-mono-stack); + font-size: 9px; +} + +.pg-chart__anomaly-label { + fill: #ffb0a7; + font-family: var(--font-mono-stack); + font-size: 9px; + letter-spacing: 0.02em; +} + +.pg-chart__tip { + fill: var(--fg-2); + font-family: var(--font-mono-stack); + font-size: 9px; +} + +.pg-chart__tip-strong { + fill: var(--fg); + font-family: var(--font-mono-stack); + font-size: 10px; +} + +/* ========================================================================== + Timeline dock + ========================================================================== */ + +.pg-dock { + display: grid; + grid-template-columns: auto 190px minmax(200px, 1fr) auto; + align-items: center; + gap: 16px; + height: var(--pg-dock); + flex: none; + padding: 0 16px; + border-top: 1px solid var(--line); + background: rgba(5, 6, 10, 0.86); + backdrop-filter: blur(18px); +} + +.pg-dock__controls { + display: flex; + align-items: center; + gap: 4px; +} + +.pg-icon-btn, +.pg-play { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--line); + border-radius: 7px; + color: var(--fg-2); + transition: + color 0.25s, + border-color 0.25s, + background-color 0.25s; +} + +.pg-icon-btn:hover, +.pg-play:hover { + color: var(--fg); + border-color: var(--line-strong); + background: rgba(255, 255, 255, 0.05); +} + +.pg-play { + background: var(--fg); + border-color: var(--fg); + color: var(--ink-base); +} + +.pg-play:hover { + background: #fff; + color: var(--ink-base); +} + +.pg-dock__time { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} + +.pg-dock__tc { + color: var(--fg); + font-family: var(--font-mono-stack); + font-size: 12px; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} + +.pg-dock__turn { + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; + white-space: nowrap; +} + +.pg-dock__track { + position: relative; + height: 34px; +} + +.pg-dock__rail { + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 1px; + background: rgba(255, 255, 255, 0.12); +} + +.pg-dock__fill { + position: absolute; + left: 0; + top: 50%; + height: 1px; + background: linear-gradient(90deg, var(--violet), var(--cyan)); + opacity: 0.75; +} + +.pg-dock__tick { + position: absolute; + top: 50%; + width: 18px; + height: 18px; + margin-left: -9px; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; +} + +.pg-dock__tick span { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--fg-3); + transition: + background-color 0.25s, + box-shadow 0.25s, + transform 0.25s; +} + +.pg-dock__tick:hover span { + background: var(--fg); +} + +.pg-dock__tick[data-active="true"] span { + background: var(--violet-soft); + box-shadow: 0 0 10px rgba(182, 168, 255, 0.8); + transform: scale(1.35); +} + +.pg-dock__anomaly { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; +} + +.pg-dock__anomaly i { + width: 7px; + height: 7px; + transform: rotate(45deg); + border: 1px solid var(--coral); + background: rgba(255, 122, 107, 0.35); + box-shadow: 0 0 12px rgba(255, 122, 107, 0.65); +} + +.pg-dock__playhead { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + color: var(--violet-soft); + filter: drop-shadow(0 0 10px rgba(139, 124, 255, 0.65)); + z-index: 2; +} + +.pg-dock__playhead::before { + content: ""; + position: absolute; + top: 50%; + right: 100%; + width: 26px; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(139, 124, 255, 0.5)); +} + +.pg-dock__meta { + display: flex; + align-items: center; + gap: 10px; +} + +.pg-speed { + min-width: 38px; + height: 28px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--fg-2); + font-family: var(--font-mono-stack); + font-size: 11px; + transition: + color 0.25s, + border-color 0.25s; +} + +.pg-speed:hover { + color: var(--fg); + border-color: var(--line-strong); +} + +.pg-dock__keys { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.pg-mobile-tabs { + display: none; +} + +/* ========================================================================== + Responsive + ========================================================================== */ + +@media (max-width: 1240px) { + .dashboard-page { + --pg-rail: 270px; + --pg-panel: 350px; + } + + .pg-dock { + grid-template-columns: auto 170px minmax(140px, 1fr) auto; + gap: 10px; + } + + .pg-dock__keys { + display: none; + } +} + +@media (max-width: 980px) { + .pg-pills { + display: none; + } + + .pg-top__title span { + display: none; + } +} + +@media (max-width: 900px) { + .dashboard-page .app { + height: auto; + min-height: 100dvh; + overflow: visible; + } + + .pg-shell { + height: auto; + min-height: 100dvh; + overflow: visible; + } + + .pg-top { + position: sticky; + top: 0; + z-index: 20; + height: 56px; + } + + .pg-body { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + } + + .pg-work { + order: 1; + display: flex; + flex-direction: column; + gap: 8px; + } + + .pg-rail-wrap { + order: 2; + } + + .pg-stage { + position: relative; + height: 46dvh; + min-height: 320px; + border-radius: 14px; + } + + .pg-stage__top { + left: 8px; + right: 8px; + top: 8px; + flex-direction: column; + align-items: flex-start; + gap: 6px; + } + + .pg-stage__tag { + font-size: 11px; + } + + .pg-presets { + flex-wrap: wrap; + } + + .pg-stage__legend { + left: 8px; + bottom: 8px; + max-width: 210px; + gap: 3px 9px; + } + + .pg-city__hint { + display: none; + } + + .pg-autopsy { + position: static; + width: auto; + max-height: none; + padding: 12px; + } + + .pg-mobile-tabs { + display: flex; + gap: 4px; + padding: 4px; + border: 1px solid var(--line); + border-radius: 9px; + background: rgba(255, 255, 255, 0.025); + } + + .pg-mobile-tabs button { + flex: 1; + height: 32px; + border-radius: 6px; + color: var(--fg-3); + font-family: var(--font-mono-stack); + font-size: 11px; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + .pg-mobile-tabs button[data-active="true"] { + color: var(--fg); + background: rgba(139, 124, 255, 0.14); + } + + .pg-shell[data-mobile="tape"] .pg-autopsy { + display: none; + } + + .pg-shell[data-mobile="autopsy"] .pg-rail-wrap { + display: none; + } + + .pg-rail { + height: auto; + max-height: none; + } + + .pg-dock { + position: sticky; + bottom: 0; + z-index: 20; + grid-template-columns: auto 1fr auto; + grid-template-rows: 30px auto; + height: auto; + padding: 7px 10px 8px; + gap: 4px 10px; + background: rgba(5, 6, 10, 0.92); + backdrop-filter: blur(20px); + } + + .pg-dock__track { + grid-column: 1 / -1; + grid-row: 1; + height: 28px; + } + + .pg-dock__controls { + grid-row: 2; + } + + .pg-dock__time { + grid-row: 2; + } + + .pg-dock__meta { + grid-row: 2; + justify-self: end; + } + + .pg-dock__keys { + display: none; + } +} + +@media (max-width: 520px) { + .pg-top { + padding: 0 10px; + } + + .pg-top__title strong { + font-size: 18px; + } + + .pg-pill-btn { + padding: 0 8px; + font-size: 11px; + } + + .pg-live { + display: none; + } + + .pg-stage__legend span:nth-child(3), + .pg-stage__legend span:nth-child(4) { + display: none; + } + + .pg-dock__turn { + display: none; + } + + .pg-dock__tc { + font-size: 11px; + } + + .pg-verdict__main strong { + font-size: 34px; + } +} + +@media (prefers-reduced-motion: reduce) { + .pg-live[data-on="true"] i, + .pg-rec[data-on="true"] i { + animation: none; + } +} + +/* ── city canvas (served by the recorder, same skin as /playground) ─────── */ +.pg-city{position:relative;width:100%;height:100%} +.pg-city canvas{display:block;width:100%;height:100%;outline:none} +#tip{position:fixed;z-index:80;display:none;pointer-events:none;left:0;top:0} +.pg-stage__empty{position:absolute;inset:0;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:28px;text-align:center;pointer-events:none} +.pg-stage__empty h3{font-family:var(--font-sans-stack);font-size:15px;font-weight:500;color:var(--fg)} +.pg-stage__empty p{max-width:360px;color:var(--fg-3);font-family:var(--font-mono-stack);font-size:11px;line-height:1.7} +.pg-stage__empty .ghost-mark--glow{opacity:.8;margin-bottom:2px} +.pg-pill-btn[data-on="true"]{color:var(--violet-soft);border-color:rgba(139,124,255,.4);background:rgba(139,124,255,.1)} +.pg-rail,.pg-autopsy,.pg-details .diff{scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.14) transparent} +.pg-autopsy{overscroll-behavior:contain} +.pg-block .pg-empty{min-height:64px;padding:10px} +.pg-block .pg-empty p{font-size:11px} +.pg-chart__svg{min-height:74px} +.pg-verdict__actions a{display:inline-flex;align-items:center;justify-content:center;gap:6px;flex:1;height:31px;border:1px solid var(--line-strong);border-radius:7px;background:rgba(255,255,255,.04);color:var(--fg);font-family:var(--font-mono-stack);font-size:11px} +.pg-verdict__actions a:hover{background:rgba(255,255,255,.08)} +.pg-rail__list{overflow:visible} +.pg-dock__tick{background:none} +.pg-stage__tag,.pg-pill{text-overflow:ellipsis;overflow:hidden} + +/* the autopsy panel floats over the stage — keep centred empty states in the + visible part of the city, not underneath the panel. */ +.pg-stage__empty{right:calc(var(--pg-panel) + 20px)} +@media (max-width:900px){.pg-stage__empty{right:0}} + +/* keep the city itself inside the free stage area (the autopsy panel floats + over the right edge, exactly like /playground). */ +@media (min-width:901px){.pg-stage__canvas{right:calc(var(--pg-panel) + 24px)}} +@media (max-width:520px){ + #btnReload .pg-btn-label{display:none} + #btnReload{padding:0 9px} + .pg-top__brand{gap:8px} +} + +@media (min-width:901px){.pg-stage__empty p{max-width:420px}} diff --git a/packages/ghost/lib/city.mjs b/packages/ghost/lib/city.mjs new file mode 100644 index 0000000..ec91240 --- /dev/null +++ b/packages/ghost/lib/city.mjs @@ -0,0 +1,86 @@ + +// Ghost City — the recorder's own /playground. Same shell, same 3D city, same +// autopsy panel and tape dock as the published dashboard; the data is whatever +// this recorder has actually captured (or an explicitly-labelled demo fixture). +import fs from "node:fs"; + +const CSS = fs.readFileSync(new URL("./city.css", import.meta.url), "utf8"); +const CLIENT = fs.readFileSync(new URL("./city-client.mjs", import.meta.url), "utf8"); + +const GHOST = (size) => + ''; + +const I = (d, s) => + '"; + +export function cityHtml3d() { + return "" + + '' + + "Ghost City — live playground" + + "" + + '' + + "
    " + + + // ── top bar ──────────────────────────────────────────────────────────── + '
    ' + GHOST(28) + + '
    GhostCode Autopsy
    ' + + '
    ' + + '…' + + 'ghost watch · local' + + '…
    ' + + '
    ' + + 'Live' + + '' + + '' + + '' + I('', 16) + "" + + "
    " + + + // ── body ─────────────────────────────────────────────────────────────── + '
    ' + + '
    ' + + + '
    ' + + '' + + '
    ' + + '
    Drag to orbit · click a tower to inspect
    ' + + '
    ' + + '
    ' + + '
    ' + I('') + "code city · 0 turns" + + '
    ' + + '" + + '" + + "
    " + + '
    ' + + "" + I('', 13) + "healthy" + + "" + I('', 13) + "anomaly" + + "" + I('', 13) + "agent-written" + + "" + I('', 13) + "human" + + "
    " + + + '
    ' + + '' + + '
    ' + + '' + + "
    " + + + // ── dock ─────────────────────────────────────────────────────────────── + '
    ' + + '
    ' + + '" + + '" + + '
    " + + '
    REC' + + '00:00:00t0 / 0
    ' + + '
    ' + + '
    ' + + 'Space←→
    ' + + "
    " + + ""; +} diff --git a/packages/ghost/lib/shadowgit.mjs b/packages/ghost/lib/shadowgit.mjs index 1c80b67..41ea000 100644 --- a/packages/ghost/lib/shadowgit.mjs +++ b/packages/ghost/lib/shadowgit.mjs @@ -25,7 +25,10 @@ export function commitSnapshot(repo, message = "ghost checkpoint") { execFileSync("git", args, { cwd: repo, env: { ...env, ...extraEnv }, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); try { git(["read-tree", "HEAD"]); } catch { /* empty repo without HEAD */ } - git(["add", "-A"]); + // .ghost is the recorder's own bookkeeping (shadow index + patches). Staging + // it would grow every snapshot by megabytes and eventually blow maxBuffer — + // the city would silently stop growing. Exclude it at the source. + git(["add", "-A", "--", ".", ":(exclude).ghost", ":(exclude).ghost/**"]); const tree = git(["write-tree"]); let parent = null; try { parent = git(["rev-parse", "HEAD"]); } catch { /* first snapshot */ } @@ -33,14 +36,14 @@ export function commitSnapshot(repo, message = "ghost checkpoint") { const sha = git(commitArgs, { GIT_AUTHOR_DATE: new Date().toISOString(), GIT_COMMITTER_DATE: new Date().toISOString() }); const diffOut = execFileSync( "git", ["diff", "--binary", ...(parent ? [parent] : ["4b825dc642cb6eb9a060e54bf8d69288fbee4904"]), sha], - { cwd: repo, encoding: "utf8", maxBuffer: 1024 * 1024 * 16 }, + { cwd: repo, encoding: "utf8", maxBuffer: 1024 * 1024 * 64 }, ).trim(); return { sha, diff: diffOut, changedFiles: listChangedFiles(repo, parent ? parent : "4b825dc642cb6eb9a060e54bf8d69288fbee4904", sha) }; } function listChangedFiles(repo, from, to) { try { - const out = execFileSync("git", ["diff", "--name-only", from, to], { cwd: repo, encoding: "utf8" }).trim(); + const out = execFileSync("git", ["diff", "--name-only", from, to, "--", ".", ":(exclude).ghost", ":(exclude).ghost/**"], { cwd: repo, encoding: "utf8" }).trim(); return out ? out.split("\n").map((s) => s.trim()).filter(Boolean) : []; } catch { return []; } }