Merge pull request #70 from SquidSec/fix/ops-ux-nav-ai-brand-scroll

fix(ops): INK brand, mobile drawer nav, resizable dock
This commit is contained in:
☣️ Mr. The Plague ☣️
2026-08-02 06:23:52 -05:00
committed by GitHub
3 changed files with 331 additions and 111 deletions
+2 -1
View File
@@ -266,7 +266,8 @@ OPENAI_TOOLS: list[dict[str, Any]] = [
},
]
CHAT_SYSTEM_PROMPT = """You are SquidC5 Admin AI — the operator assistant for this C5 (Command, Control, Cognitive, Collaborative, Coordination) teamserver.
CHAT_SYSTEM_PROMPT = """You are INK — SquidC5's neural operator assistant for this C5 (Command, Control, Cognitive, Collaborative, Coordination) teamserver.
When referring to yourself, use the name INK.
You can:
1) Answer general security/ops questions clearly and helpfully.
+46 -61
View File
@@ -69,7 +69,7 @@
{ a: "hitl", t: "HITL queue" },
],
ai: [
{ a: "admin-ai", t: "Admin AI" },
{ a: "admin-ai", t: "INK" },
{ a: "llm-connections", t: "LLM connections" },
],
observe: [
@@ -1095,46 +1095,36 @@
const root = el("view-ai");
if (!root) return;
if (!can("ai:use") && !can("admin")) {
root.innerHTML = `<div class="empty-state"><strong>AI locked</strong>Need <code>ai:use</code> scope.</div>`;
root.innerHTML = `<div class="empty-state"><strong>INK locked</strong>Need <code>ai:use</code> scope.</div>`;
return;
}
root.innerHTML = `
<div class="form-grid ai-layout">
<div class="work-panel">
<div class="wp-head">Operator chat</div>
<div class="wp-body">
<p class="muted" style="font-size:0.78rem;margin:0 0 8px">
Free-form chat with railed tools: sessions, listeners, tasks, payloads, metrics, events, audit.
Example: <em>“Setup a reverse shell listener on 4444 and start it.”</em>
</p>
<label>LLM connection</label>
<select id="aiLlmPick"><option value="">Loading…</option></select>
<label>Message</label>
<textarea id="aiData" rows="4" placeholder="Ask a question or give an ops instruction…"></textarea>
<div class="row">
<button type="button" class="primary" id="aiRun">Send</button>
<button type="button" id="aiClearPage">Clear chat</button>
<button type="button" id="aiStatus">AI status</button>
<button type="button" id="aiTools">Tools</button>
<button type="button" id="aiOpenDrawer">Floating chat</button>
</div>
<div id="aiPageLog" class="outbox" style="max-height:min(420px,45vh);min-height:160px"></div>
<details style="margin-top:10px">
<summary class="muted" style="cursor:pointer;font-size:0.78rem">Legacy capability runner</summary>
<label style="margin-top:8px">Capability</label>
<select id="aiCap">${AI_CAPS.map((c) => `<option value="${c}">${c}</option>`).join("")}</select>
<div class="row"><button type="button" id="aiRunCap">Run capability</button></div>
<div class="outbox empty" id="aiOut">—</div>
</details>
</div>
<div class="work-panel" style="min-height:min(70vh,640px);display:flex;flex-direction:column">
<div class="wp-head">
<span class="ink-brand">◈ INK</span>
<span class="muted" style="margin-left:8px;font-size:0.75rem;font-weight:500;text-transform:none;letter-spacing:0">neural operator</span>
</div>
<div class="work-panel">
<div class="wp-head">Configure LLM (BYO)</div>
<div class="wp-body">${llmFormHtml("llm")}</div>
<div class="wp-body" style="display:flex;flex-direction:column;flex:1;min-height:0">
<p class="muted" style="font-size:0.78rem;margin:0 0 8px">
Chat with the op. INK inspects sessions, listeners, events, and runs railed actions
(e.g. <em>“setup reverse shell on 4444”</em>). Configure models under <strong>Admin</strong>.
</p>
<label>LLM connection</label>
<select id="aiLlmPick"><option value="">Loading…</option></select>
<div id="aiPageLog" class="outbox" style="flex:1;max-height:none;min-height:220px;margin-top:10px"></div>
<label style="margin-top:10px">Message</label>
<textarea id="aiData" rows="3" placeholder="Talk to INK… Enter to send, Shift+Enter for newline"></textarea>
<div class="row">
<button type="button" class="primary" id="aiRun">Send</button>
<button type="button" id="aiClearPage">Clear</button>
<button type="button" id="aiStatus">Status</button>
<button type="button" id="aiTools">Tools</button>
<button type="button" id="aiOpenDrawer">Pop-out</button>
</div>
<div class="outbox empty hidden" id="aiOut">—</div>
</div>
</div>
`;
bindLlmForm("llm");
const refreshPick = async () => {
const llms = await loadSavedLlms();
const prev = el("aiLlmPick")?.value || loadSel("sc5_ops_llm") || "";
@@ -1147,7 +1137,7 @@
saveSel("sc5_ops_llm", el("aiLlmPick").value || "");
if (el("aiLlmGlobal") && el("aiLlmPick").value) el("aiLlmGlobal").value = el("aiLlmPick").value;
};
if (el("aiRun")) el("aiRun").onclick = async () => {
const sendPage = async () => {
const msg = (el("aiData") && el("aiData").value) || "";
if (!msg.trim()) return showError("Enter a message");
el("aiRun").disabled = true;
@@ -1156,20 +1146,28 @@
if (el("aiData")) el("aiData").value = "";
} finally { el("aiRun").disabled = false; }
};
if (el("aiRun")) el("aiRun").onclick = sendPage;
if (el("aiData")) {
el("aiData").onkeydown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendPage();
}
};
}
if (el("aiClearPage")) el("aiClearPage").onclick = () => clearAiChat();
if (el("aiRunCap")) el("aiRunCap").onclick = () => runAiCapability(el("aiCap").value, el("aiData").value, "aiOut");
if (el("aiStatus")) el("aiStatus").onclick = async () => {
try {
const r = await api("GET", "/api/v1/ai/status");
el("aiOut").classList.remove("hidden", "empty");
el("aiOut").textContent = JSON.stringify(r, null, 2);
el("aiOut").classList.remove("empty");
} catch (e) { showError(String(e.message || e)); }
};
if (el("aiTools")) el("aiTools").onclick = async () => {
try {
const r = await api("GET", "/api/v1/ai/tools");
el("aiOut").classList.remove("hidden", "empty");
el("aiOut").textContent = JSON.stringify(r, null, 2);
el("aiOut").classList.remove("empty");
} catch (e) { showError(String(e.message || e)); }
};
if (el("aiOpenDrawer")) el("aiOpenDrawer").onclick = () => openAiDrawer(true);
@@ -1205,7 +1203,7 @@
if (tools.some((t) => t.ok && /listener|session|task|payload/i.test(t.tool || ""))) {
if (window.__SC5_refresh) try { await window.__SC5_refresh(); } catch (_) {}
}
showOk(r.mode === "offline" ? "AI (offline tools)" : "AI reply");
showOk(r.mode === "offline" ? "INK (offline)" : "INK replied");
return r;
} catch (e) {
const err = String(e.message || e);
@@ -1215,26 +1213,6 @@
}
}
async function runAiCapability(capability, user_data, outId) {
try {
const body = {
capability: capability || "recon_assist",
user_data: user_data || "",
};
const llm_id = selectedLlmId();
if (llm_id) body.llm_id = llm_id;
const r = await api("POST", "/api/v1/ai/run", body);
const text = typeof r === "string" ? r : JSON.stringify(r, null, 2);
if (outId && el(outId)) {
el(outId).textContent = text;
el(outId).classList.remove("empty");
}
showOk("Capability complete");
return r;
} catch (e) {
showError(String(e.message || e));
}
}
function appendAiChat(who, text, toolTrace) {
const log = el("aiChatLog");
@@ -1245,7 +1223,7 @@
div.className = "ai-msg " + (who === "user" ? "user" : "bot");
const w = document.createElement("div");
w.className = "who";
w.textContent = who === "user" ? "You" : "Admin AI";
w.textContent = who === "user" ? "You" : "INK";
const b = document.createElement("div");
b.style.whiteSpace = "pre-wrap";
b.textContent = text;
@@ -1390,7 +1368,14 @@
</div>` : ""}
</div>
`;
if (can("llm:manage") || can("admin")) bindLlmForm("adLlm");
if (can("llm:manage") || can("admin")) {
bindLlmForm("adLlm");
window.__SC5_refreshLlms = async () => {
const llms = await loadSavedLlms();
fillLlmSelect(el("aiLlmGlobal"), llms, loadSel("sc5_ops_llm") || "");
fillLlmSelect(el("aiLlmPick"), llms, loadSel("sc5_ops_llm") || "");
};
}
function selectedScopes() {
return Array.from(document.querySelectorAll(".ad-scope:checked")).map((c) => c.value);
}
+283 -49
View File
@@ -61,6 +61,28 @@
.hidden { display: none !important; }
a { color: var(--pink2); }
/* Themed scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: rgba(233,30,140,0.45) rgba(255,255,255,0.04);
}
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track {
background: rgba(255,255,255,0.03);
border-radius: 999px;
}
*::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(233,30,140,0.55), rgba(167,139,250,0.4));
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(244,114,182,0.75), rgba(167,139,250,0.55));
background-clip: padding-box;
}
*::-webkit-scrollbar-corner { background: transparent; }
/* —— App shell —— */
.app {
display: grid;
@@ -121,7 +143,27 @@
.pill.live { color: var(--pink2); border-color: rgba(233,30,140,0.4); }
.top button { padding: 5px 10px; font-size: 0.78rem; }
/* Sidebar */
/* Sidebar / mobile drawer */
.nav-burger {
display: none;
flex-shrink: 0;
width: 40px; height: 36px; padding: 0;
align-items: center; justify-content: center;
flex-direction: column; gap: 5px;
}
.nav-burger span {
display: block; width: 18px; height: 2px;
background: var(--text); border-radius: 2px;
transition: transform 0.15s, opacity 0.15s;
}
.app.nav-open .nav-burger span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.app.nav-open .nav-burger span:nth-child(2) { opacity: 0; }
.app.nav-open .nav-burger span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
.nav-backdrop {
display: none; position: fixed; inset: 0; z-index: 54;
background: rgba(0,0,0,0.55);
}
.nav-backdrop.show { display: block; }
.side {
grid-area: side;
background: var(--bg2);
@@ -210,20 +252,63 @@
background: rgba(0,0,0,0.55);
}
/* Bottom console */
/* Bottom console — resizable height + pane split */
.bottom {
grid-area: bottom;
border-top: 1px solid var(--border);
background: #0a0a0e;
display: grid;
grid-template-columns: 1fr 1fr;
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
}
.bottom-resize {
flex: 0 0 10px;
cursor: ns-resize;
background: transparent;
position: relative;
z-index: 6;
touch-action: none;
}
.bottom-resize::after {
content: "";
position: absolute; left: 50%; top: 3px;
width: 48px; height: 3px; margin-left: -24px;
border-radius: 999px;
background: rgba(233,30,140,0.45);
}
.bottom-resize:hover::after,
.bottom-resize.dragging::after {
background: rgba(244,114,182,0.9);
box-shadow: 0 0 10px rgba(233,30,140,0.45);
}
.bottom-panes {
flex: 1;
display: grid;
grid-template-columns: var(--pane-split, 1fr 6px 1fr);
min-height: 0;
min-width: 0;
}
.bottom-pane {
display: flex; flex-direction: column; min-height: 0; min-width: 0;
border-right: 1px solid var(--border);
}
.bottom-pane:last-child { border-right: 0; }
.pane-vsplit {
cursor: ew-resize;
background: rgba(255,255,255,0.06);
position: relative;
touch-action: none;
z-index: 5;
}
.pane-vsplit::after {
content: "";
position: absolute; top: 50%; left: 50%;
width: 3px; height: 28px; margin: -14px 0 0 -1.5px;
border-radius: 999px; background: rgba(233,30,140,0.4);
}
.pane-vsplit:hover::after,
.pane-vsplit.dragging::after {
background: rgba(244,114,182,0.9);
}
.bottom-pane .bp-head {
display: flex; align-items: center; gap: 8px;
padding: 4px 10px; border-bottom: 1px solid var(--border);
@@ -238,6 +323,12 @@
flex: 1; overflow: auto; padding: 6px 10px;
font-family: var(--mono); font-size: 0.72rem; line-height: 1.4;
color: #c4c4d0; white-space: pre-wrap; word-break: break-word;
min-height: 0;
}
.ink-brand {
background: linear-gradient(90deg, var(--pink2), var(--purple));
-webkit-background-clip: text; background-clip: text;
color: transparent; font-weight: 800; letter-spacing: 0.04em;
}
/* Content primitives */
@@ -463,11 +554,10 @@
}
body { overflow: hidden; }
.app {
grid-template-rows: var(--top-h) var(--nav-h) 1fr var(--bottom-h);
grid-template-rows: var(--top-h) 1fr var(--bottom-h);
grid-template-columns: 1fr;
grid-template-areas:
"top"
"side"
"main"
"bottom";
height: 100dvh;
@@ -476,24 +566,21 @@
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.app.no-bottom {
grid-template-rows: var(--top-h) var(--nav-h) 1fr;
grid-template-rows: var(--top-h) 1fr;
grid-template-areas:
"top"
"side"
"main";
}
.app.no-ctx {
grid-template-columns: 1fr;
grid-template-areas:
"top"
"side"
"main"
"bottom";
}
.app.no-bottom.no-ctx {
grid-template-areas:
"top"
"side"
"main";
}
@@ -532,33 +619,44 @@
}
.top #btnRefresh { min-width: 36px; padding: 0 8px; }
/* —— Horizontal nav (easy swipe scroll) —— */
/* —— Hamburger drawer nav —— */
.nav-burger { display: inline-flex; }
.side {
position: sticky; top: var(--top-h); z-index: 45;
flex-direction: row; border-right: 0; border-bottom: 1px solid var(--border);
overflow: hidden; height: var(--nav-h); max-height: var(--nav-h);
position: fixed;
top: 0; left: 0; bottom: 0;
width: min(300px, 86vw);
z-index: 55;
border-right: 1px solid var(--border2);
box-shadow: 16px 0 40px rgba(0,0,0,0.45);
transform: translateX(-105%);
transition: transform 0.22s ease;
height: 100dvh;
max-height: none;
padding-top: env(safe-area-inset-top, 0px);
background: var(--bg2);
}
.app.nav-open .side { transform: translateX(0); }
.side-nav {
flex-direction: row; align-items: center;
padding: 4px 8px; gap: 6px;
overflow-x: auto; overflow-y: hidden;
flex-direction: column;
padding: 12px 10px;
gap: 4px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scroll-snap-type: x proximity;
flex: 1;
scrollbar-width: none;
}
.side-nav::-webkit-scrollbar { display: none; }
.nav-item {
white-space: nowrap; flex: 0 0 auto;
padding: 8px 14px; min-height: 38px;
font-size: 0.8rem; scroll-snap-align: start;
width: 100%;
padding: 14px 14px;
min-height: 48px;
font-size: 1rem;
border: 1px solid var(--border);
}
.nav-item .badge-n { display: none; }
.nav-item .ico { display: none; }
.side-foot { display: none; }
.nav-item .ico { display: inline-block; font-size: 1.05rem; }
.nav-item .badge-n { display: inline-block; }
.side-foot {
display: block;
padding: 12px 14px calc(12px + env(safe-area-inset-bottom, 0px));
}
/* —— Context as scrollable bottom sheet —— */
.ctx {
@@ -633,11 +731,19 @@
-webkit-overflow-scrolling: touch;
min-height: 0;
}
.bottom {
.bottom { min-height: 0; }
.bottom-panes {
grid-template-columns: 1fr;
min-height: 0;
grid-template-rows: var(--pane-stack, 1fr 6px 1fr);
}
.pane-vsplit {
cursor: ns-resize;
width: auto; height: 6px;
}
.pane-vsplit::after {
width: 36px; height: 3px; top: 50%; left: 50%;
margin: -1.5px 0 0 -18px;
}
.bottom-pane:first-child { border-right: 0; border-bottom: 1px solid var(--border); max-height: 50%; }
.bottom-pane .bp-body {
font-size: 0.68rem;
overflow-y: auto;
@@ -684,6 +790,9 @@
<body>
<div class="app" id="app">
<header class="top">
<button type="button" class="nav-burger" id="navBurger" title="Menu" aria-label="Open navigation" aria-expanded="false">
<span></span><span></span><span></span>
</button>
<img class="logo" src="/ops/assets/squidsec-logo-96.png" alt=""
onerror="this.src='/ops/assets/squidsec-logo-256.png'" />
<div class="title">SQUID<span>C5</span></div>
@@ -697,7 +806,8 @@
<button type="button" id="btnRefresh" title="Refresh data">↻</button>
</header>
<nav class="side" aria-label="Primary">
<div class="nav-backdrop" id="navBackdrop" hidden></div>
<nav class="side" id="sideNavPanel" aria-label="Primary">
<div class="side-nav" id="sideNav">
<button type="button" class="nav-item active" data-view="dashboard"><span class="ico">▣</span> Dashboard</button>
<button type="button" class="nav-item" data-view="sessions"><span class="ico">◉</span> Sessions <span class="badge-n" id="navNSes">0</span></button>
@@ -705,7 +815,7 @@
<button type="button" class="nav-item" data-view="payloads"><span class="ico">⌘</span> Payloads</button>
<button type="button" class="nav-item" data-view="postex"><span class="ico">⚡</span> Post-Ex</button>
<button type="button" class="nav-item" data-view="collab"><span class="ico">⇄</span> Collab</button>
<button type="button" class="nav-item" data-view="ai" id="navAi"><span class="ico">✦</span> AI</button>
<button type="button" class="nav-item" data-view="ai" id="navAi"><span class="ico">◈</span> INK</button>
<button type="button" class="nav-item" data-view="observe"><span class="ico">▤</span> Observe</button>
<button type="button" class="nav-item hidden" data-view="admin" id="navAdmin"><span class="ico">⚙</span> Admin</button>
</div>
@@ -760,22 +870,24 @@
</div>
</main>
<!-- Global AI assistant (all pages) -->
<button type="button" id="aiFab" class="ai-fab hidden" title="Admin AI assistant">✦ AI</button>
<div id="aiDrawer" class="ai-drawer hidden" aria-label="Admin AI chat">
<!-- INK — SquidC5 neural operator (global) -->
<button type="button" id="aiFab" class="ai-fab hidden" title="INK — neural operator">◈ INK</button>
<div id="aiDrawer" class="ai-drawer hidden" aria-label="INK chat">
<div class="ai-drawer-head">
<strong>Admin AI</strong>
<strong class="ink-brand">◈ INK</strong>
<span class="muted" style="font-size:0.68rem;font-weight:500">neural operator</span>
<a class="doc-link" href="https://github.com/SquidSec/SquidC5/blob/master/docs/user-guide.md#admin-ai" target="_blank" rel="noopener noreferrer" style="font-size:0.7rem">Docs</a>
<button type="button" id="aiDrawerClose" style="margin-left:auto">✕</button>
</div>
<div class="ai-drawer-body" id="aiChatLog"></div>
<div class="ai-drawer-foot">
<select id="aiLlmGlobal" title="LLM connection"></select>
<textarea id="aiPromptGlobal" rows="3" placeholder="Ask anything — e.g. list sessions, setup reverse shell on 4444…"></textarea>
<textarea id="aiPromptGlobal" rows="3" placeholder="Ask INK anything — list sessions, spin up a listener…"></textarea>
<div class="row" style="margin:0;gap:6px">
<button type="button" class="primary" id="aiSendGlobal" style="flex:1">Send</button>
<button type="button" id="aiClearGlobal" title="Clear chat">Clear</button>
</div>
<p class="muted" style="margin:0;font-size:0.62rem">Enter to send · Shift+Enter newline</p>
</div>
</div>
@@ -791,14 +903,18 @@
</aside>
<div class="ctx-backdrop hidden" id="ctxBackdrop" aria-hidden="true"></div>
<footer class="bottom">
<div class="bottom-pane">
<div class="bp-head"><span class="live-dot"></span> Event stream <span class="muted" id="eventCount" style="margin-left:auto;text-transform:none;letter-spacing:0">0</span></div>
<div class="bp-body" id="eventsBox">Connect to stream events…</div>
</div>
<div class="bottom-pane">
<div class="bp-head">Output / console</div>
<div class="bp-body" id="consoleBox">Command and task output appears here.</div>
<footer class="bottom" id="bottomDock">
<div class="bottom-resize" id="bottomResize" title="Drag to resize panel height" role="separator" aria-orientation="horizontal"></div>
<div class="bottom-panes" id="bottomPanes">
<div class="bottom-pane" id="paneEvents">
<div class="bp-head"><span class="live-dot"></span> Event stream <span class="muted" id="eventCount" style="margin-left:auto;text-transform:none;letter-spacing:0">0</span></div>
<div class="bp-body" id="eventsBox">Connect to stream events…</div>
</div>
<div class="pane-vsplit" id="paneSplit" title="Drag to resize panes" role="separator" aria-orientation="vertical"></div>
<div class="bottom-pane" id="paneConsole">
<div class="bp-head">Output / console</div>
<div class="bp-body" id="consoleBox">Command and task output appears here.</div>
</div>
</div>
</footer>
</div>
@@ -933,11 +1049,35 @@
payloads: { title: "Payloads", sub: "Generate implants and templates", doc: "payloads-and-implants" },
postex: { title: "Post-Ex", sub: "Files, SOCKS, modules on selected session", doc: "file-ops" },
collab: { title: "Collab", sub: "Teams, chat, handoff, presence", doc: "multi-operator-collab" },
ai: { title: "Admin AI", sub: "Sandboxed allow-listed AI capabilities", doc: "admin-ai" },
ai: { title: "INK", sub: "Neural operator — chat, inspect, and drive the C5", doc: "admin-ai" },
observe: { title: "Observe", sub: "Metrics, audit, timeline, reports", doc: "timeline-and-reports" },
admin: { title: "Admin", sub: "Tokens, policy, features, LLM, MCP", doc: "feature-toggles" },
};
let currentView = "dashboard";
function closeNavDrawer() {
const app = $("app");
if (app) app.classList.remove("nav-open");
const b = $("navBurger");
if (b) b.setAttribute("aria-expanded", "false");
const bd = $("navBackdrop");
if (bd) { bd.classList.remove("show"); bd.hidden = true; }
}
function openNavDrawer() {
const app = $("app");
if (app) app.classList.add("nav-open");
const b = $("navBurger");
if (b) b.setAttribute("aria-expanded", "true");
const bd = $("navBackdrop");
if (bd) { bd.hidden = false; bd.classList.add("show"); }
}
function toggleNavDrawer() {
const app = $("app");
if (app && app.classList.contains("nav-open")) closeNavDrawer();
else openNavDrawer();
}
if ($("navBurger")) $("navBurger").onclick = (e) => { e.stopPropagation(); toggleNavDrawer(); };
if ($("navBackdrop")) $("navBackdrop").onclick = () => closeNavDrawer();
function setView(name) {
if (name === "admin" && !can("admin") && !can("tokens:manage") && !can("policy:manage")) name = "dashboard";
if (name === "ai" && !can("ai:use") && !can("admin")) name = "dashboard";
@@ -950,7 +1090,6 @@
});
const app = document.querySelector(".app");
if (app) {
// Admin: full width — hide session context + bottom event/output panes
const focusAdmin = name === "admin";
const focusWide = name === "admin" || name === "ai";
app.classList.toggle("no-ctx", focusWide);
@@ -960,6 +1099,7 @@
$("viewTitle").textContent = meta.title;
$("viewSub").textContent = meta.sub;
$("viewTools").innerHTML = "";
closeNavDrawer();
if (window.__SC5_onView) window.__SC5_onView(name);
if (window.__SC5_setPageDocs) window.__SC5_setPageDocs(name);
try { localStorage.setItem("sc5_ops_view", name); } catch (_) {}
@@ -969,6 +1109,100 @@
b.addEventListener("click", () => setView(b.getAttribute("data-view")));
});
/* —— Resizable bottom dock —— */
(function setupBottomResize() {
const root = document.documentElement;
const app = $("app");
const hHandle = $("bottomResize");
const vHandle = $("paneSplit");
function loadH() {
try {
const v = parseInt(localStorage.getItem("sc5_bottom_h") || "", 10);
if (v >= 80 && v <= Math.floor(window.innerHeight * 0.7)) {
root.style.setProperty("--bottom-h", v + "px");
}
} catch (_) {}
}
function loadSplit() {
try {
const r = parseFloat(localStorage.getItem("sc5_pane_ratio") || "");
if (r > 0.15 && r < 0.85) {
const mobile = window.matchMedia("(max-width: 900px)").matches;
if (mobile) {
root.style.setProperty("--pane-stack", r + "fr 6px " + (1 - r) + "fr");
} else {
root.style.setProperty("--pane-split", r + "fr 6px " + (1 - r) + "fr");
}
}
} catch (_) {}
}
loadH();
loadSplit();
window.addEventListener("resize", () => loadSplit());
function drag(handle, onMove, onEnd) {
if (!handle) return;
let active = false;
const start = (e) => {
if (app && app.classList.contains("no-bottom")) return;
active = true;
handle.classList.add("dragging");
e.preventDefault();
const move = (ev) => {
if (!active) return;
const pt = ev.touches ? ev.touches[0] : ev;
onMove(pt.clientX, pt.clientY);
};
const end = () => {
active = false;
handle.classList.remove("dragging");
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", end);
window.removeEventListener("touchmove", move);
window.removeEventListener("touchend", end);
if (onEnd) onEnd();
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", end);
window.addEventListener("touchmove", move, { passive: false });
window.addEventListener("touchend", end);
};
handle.addEventListener("mousedown", start);
handle.addEventListener("touchstart", start, { passive: false });
}
drag(hHandle, (_x, y) => {
const h = Math.max(80, Math.min(window.innerHeight * 0.7, window.innerHeight - y));
root.style.setProperty("--bottom-h", Math.round(h) + "px");
}, () => {
try {
const cur = getComputedStyle(root).getPropertyValue("--bottom-h").trim();
const n = parseInt(cur, 10);
if (n) localStorage.setItem("sc5_bottom_h", String(n));
} catch (_) {}
});
drag(vHandle, (x, y) => {
const panes = $("bottomPanes");
if (!panes) return;
const rect = panes.getBoundingClientRect();
const mobile = window.matchMedia("(max-width: 900px)").matches;
let r;
if (mobile) {
r = (y - rect.top) / rect.height;
} else {
r = (x - rect.left) / rect.width;
}
r = Math.max(0.18, Math.min(0.82, r));
if (mobile) {
root.style.setProperty("--pane-stack", r + "fr 6px " + (1 - r) + "fr");
} else {
root.style.setProperty("--pane-split", r + "fr 6px " + (1 - r) + "fr");
}
try { localStorage.setItem("sc5_pane_ratio", String(r)); } catch (_) {}
});
})();
/* —— Connect modal —— */
function openModal() { $("connectModal").classList.add("show"); }
function closeModal() { $("connectModal").classList.remove("show"); }