helmd: inbäddad webbklient (vanilla SPA) + Docker-image för Pi5 (LAN-only)
All checks were successful
helmd-image / build (push) Successful in 36s
helmd-release / build-release (push) Successful in 1m6s

- web/: token-login, sessionslista, ANSI-skärmvy (SSE+poll), promptfält,
  frågekort, tangentrad, delningsflik (bild/HTML-sandbox/markdown), inställningar
- go:embed via webfs.go — servern förblir en enda binär, strikt CSP
- browser-verifierad mot riktig agy (fråga -> kort -> svar -> kört)
- Dockerfile (alpine + tmux/bash) + helmd-image.yaml -> localhost:5000/helmd
- integrationstest utökat till 29 gröna

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 22:17:12 +02:00
parent f9f94e59bc
commit 12ffcaa2bd
13 changed files with 830 additions and 6 deletions

78
helmd/web/ansi.js Normal file
View File

@@ -0,0 +1,78 @@
// Minimal ANSI(SGR) -> HTML. Stödjer 16/256-färg, truecolor, bold/dim/
// invers/understruket. Allt annat (cursorstyrning m.m.) strippas —
// helmd skickar capture-pane-text, så bara färgkoder förekommer.
const BASE = [
"#1c1f26", "#e5605c", "#3fca7c", "#e0a63f",
"#4f9cf9", "#b877d9", "#3fc4c4", "#cfd6e4",
"#5b6472", "#ff7b76", "#5fe09a", "#ffc65c",
"#7bc4ff", "#d49df0", "#6ee0e0", "#ffffff",
];
function color256(n) {
n = Number(n);
if (n < 16) return BASE[n];
if (n < 232) {
const c = n - 16, v = [0, 95, 135, 175, 215, 255];
return `rgb(${v[(c / 36) | 0]},${v[((c / 6) | 0) % 6]},${v[c % 6]})`;
}
const g = 8 + (n - 232) * 10;
return `rgb(${g},${g},${g})`;
}
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
export function ansiToHtml(text) {
let out = "", open = false;
const st = { fg: null, bg: null, bold: false, dim: false, ul: false, inv: false };
const flushSpan = () => { if (open) { out += "</span>"; open = false; } };
const openSpan = () => {
const css = [];
let fg = st.fg, bg = st.bg;
if (st.inv) [fg, bg] = [bg || "#cfd6e4", fg || "#05070b"];
if (fg) css.push(`color:${fg}`);
if (bg) css.push(`background:${bg}`);
if (st.bold) css.push("font-weight:700");
if (st.dim) css.push("opacity:.6");
if (st.ul) css.push("text-decoration:underline");
if (css.length) { out += `<span style="${css.join(";")}">`; open = true; }
};
// dela på escape-sekvenser; behåll SGR, släng övriga
const parts = text.split(/(\x1b\[[0-9;:]*[A-Za-z])/);
for (const p of parts) {
if (!p) continue;
if (p.startsWith("\x1b[")) {
if (!p.endsWith("m")) continue; // icke-SGR: ignorera
const codes = p.slice(2, -1).split(/[;:]/).map((x) => (x === "" ? 0 : Number(x)));
for (let i = 0; i < codes.length; i++) {
const c = codes[i];
if (c === 0) { st.fg = st.bg = null; st.bold = st.dim = st.ul = st.inv = false; }
else if (c === 1) st.bold = true;
else if (c === 2) st.dim = true;
else if (c === 4) st.ul = true;
else if (c === 7) st.inv = true;
else if (c === 22) { st.bold = st.dim = false; }
else if (c === 24) st.ul = false;
else if (c === 27) st.inv = false;
else if (c >= 30 && c <= 37) st.fg = BASE[c - 30];
else if (c === 38 && codes[i + 1] === 5) { st.fg = color256(codes[i + 2]); i += 2; }
else if (c === 38 && codes[i + 1] === 2) { st.fg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`; i += 4; }
else if (c === 39) st.fg = null;
else if (c >= 40 && c <= 47) st.bg = BASE[c - 40];
else if (c === 48 && codes[i + 1] === 5) { st.bg = color256(codes[i + 2]); i += 2; }
else if (c === 48 && codes[i + 1] === 2) { st.bg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`; i += 4; }
else if (c === 49) st.bg = null;
else if (c >= 90 && c <= 97) st.fg = BASE[c - 90 + 8];
else if (c >= 100 && c <= 107) st.bg = BASE[c - 100 + 8];
}
flushSpan();
openSpan();
} else {
out += esc(p.replace(/\x1b./g, ""));
}
}
flushSpan();
return out;
}

335
helmd/web/app.js Normal file
View File

@@ -0,0 +1,335 @@
import { ansiToHtml } from "./ansi.js";
// ---------- state ----------
let token = localStorage.getItem("helmd_token") || "";
let sessions = [];
let current = null; // valt sessionsnamn
let currentSeq = -1;
let shares = [];
let currentShare = null;
let es = null; // EventSource
let pollTimer = null;
const $ = (id) => document.getElementById(id);
// ---------- api ----------
async function api(path, opts = {}) {
opts.headers = Object.assign({ Authorization: "Bearer " + token }, opts.headers);
const r = await fetch(path, opts);
if (r.status === 401) { showLogin("Ogiltig token."); throw new Error("401"); }
if (!r.ok) {
let msg = r.statusText;
try { msg = (await r.json()).error || msg; } catch {}
throw new Error(msg);
}
const ct = r.headers.get("content-type") || "";
return ct.includes("json") ? r.json() : r.text();
}
const post = (p, body) => api(p, { method: "POST", body: JSON.stringify(body) });
// ---------- login ----------
function showLogin(err = "") {
stopLive();
$("login").classList.remove("hidden");
$("app").classList.add("hidden");
$("loginError").textContent = err;
}
async function tryConnect() {
try {
const h = await api("/api/health");
$("login").classList.add("hidden");
$("app").classList.remove("hidden");
$("verInfo").textContent = "helmd " + h.version;
startLive();
await Promise.all([refreshSessions(), refreshShares(), loadConfig()]);
if (!current && sessions.length) selectSession(sessions[0].name);
} catch (e) {
if (e.message !== "401") showLogin("Kunde inte nå servern: " + e.message);
}
}
$("tokenSave").onclick = () => {
token = $("tokenInput").value.trim();
localStorage.setItem("helmd_token", token);
tryConnect();
};
$("tokenInput").addEventListener("keydown", (e) => { if (e.key === "Enter") $("tokenSave").click(); });
$("logoutBtn").onclick = () => { localStorage.removeItem("helmd_token"); token = ""; showLogin(); };
// ---------- tabs ----------
document.querySelectorAll(".tab").forEach((b) => {
b.onclick = () => {
document.querySelectorAll(".tab").forEach((x) => x.classList.toggle("active", x === b));
["sessions", "shares", "settings"].forEach((t) =>
$("tab-" + t).classList.toggle("hidden", b.dataset.tab !== t));
if (b.dataset.tab === "shares") { $("shareBadge").classList.add("hidden"); $("shareBadge").textContent = ""; }
};
});
// ---------- SSE + poll ----------
function startLive() {
stopLive();
es = new EventSource("/api/events?token=" + encodeURIComponent(token));
es.onopen = () => $("connDot").classList.add("ok");
es.onerror = () => $("connDot").classList.remove("ok");
es.addEventListener("screen", (e) => {
const ev = JSON.parse(e.data);
if (ev.session === current) refreshScreen();
refreshSessions();
});
es.addEventListener("question", (e) => {
const ev = JSON.parse(e.data);
if (ev.session === current) renderQuestion(ev.data);
});
es.addEventListener("session", () => refreshSessions());
es.addEventListener("share", (e) => {
refreshShares();
const b = $("shareBadge");
if ($("tab-shares").classList.contains("hidden")) {
b.textContent = String(Number(b.textContent || 0) + 1);
b.classList.remove("hidden");
}
});
// fallback-poll om SSE tappas
pollTimer = setInterval(() => {
if (es && es.readyState === 2) { startLive(); return; }
if (current) refreshScreen();
}, 5000);
}
function stopLive() {
if (es) { es.close(); es = null; }
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
$("connDot").classList.remove("ok");
}
// ---------- sessioner ----------
async function refreshSessions() {
sessions = (await api("/api/sessions")) || [];
sessions.sort((a, b) => a.name.localeCompare(b.name));
const box = $("sessItems");
box.innerHTML = "";
for (const s of sessions) {
const d = document.createElement("div");
d.className = "sess-item" + (s.name === current ? " active" : "");
d.innerHTML = `<span class="dot ${s.alive ? "ok" : ""}"></span>
<span>${s.name}</span>${s.question ? '<span class="q" title="väntar på svar">❓</span>' : ""}`;
d.onclick = () => selectSession(s.name);
box.appendChild(d);
}
if (current) {
const cur = sessions.find((s) => s.name === current);
if (!cur) { current = null; $("sessPane").classList.add("hidden"); $("noSess").classList.remove("hidden"); }
else if (cur.seq !== currentSeq) refreshScreen();
}
}
async function selectSession(name) {
current = name; currentSeq = -1;
$("noSess").classList.add("hidden");
$("sessPane").classList.remove("hidden");
$("sessTitle").textContent = name;
const s = sessions.find((x) => x.name === name);
renderQuestion(s && s.question);
await refreshScreen();
refreshSessions();
}
async function refreshScreen() {
if (!current) return;
try {
const s = sessions.find((x) => x.name === current);
if (s) currentSeq = s.seq;
const txt = await api(`/api/sessions/${current}/screen?ansi=1`);
const el = $("screen");
const stick = el.scrollTop + el.clientHeight >= el.scrollHeight - 30;
el.innerHTML = ansiToHtml(txt);
if (stick) el.scrollTop = el.scrollHeight;
const st = await api(`/api/sessions/${current}`);
renderQuestion(st.question);
} catch {}
}
function renderQuestion(q) {
const card = $("questionCard");
if (!q) { card.classList.add("hidden"); card.innerHTML = ""; return; }
card.classList.remove("hidden");
card.innerHTML = `<h3>❓ ${escapeHtml(q.text)}</h3><div class="opts"></div>`;
const opts = card.querySelector(".opts");
q.options.forEach((o, i) => {
const b = document.createElement("button");
b.textContent = (o.num ? o.num + ". " : "") + o.label;
if (i === q.selected) b.classList.add("selected");
b.onclick = async () => {
b.disabled = true;
try { await post(`/api/sessions/${current}/answer`, { option: i + 1 }); }
catch (e) { alert("Kunde inte svara: " + e.message); b.disabled = false; }
};
opts.appendChild(b);
});
}
$("newSessBtn").onclick = () => $("newSessForm").classList.toggle("hidden");
$("newSessCancel").onclick = () => $("newSessForm").classList.add("hidden");
$("newSessForm").onsubmit = async (e) => {
e.preventDefault();
try {
await post("/api/sessions", {
name: $("newSessName").value.trim(),
cmd: $("newSessCmd").value.trim(),
cwd: $("newSessCwd").value.trim(),
});
$("newSessForm").reset();
$("newSessForm").classList.add("hidden");
await refreshSessions();
selectSession($("newSessName").value.trim() || sessions[sessions.length - 1]?.name);
} catch (err) { alert(err.message); }
};
$("killBtn").onclick = async () => {
if (!current || !confirm(`Döda sessionen ${current}?`)) return;
await api(`/api/sessions/${current}`, { method: "DELETE" });
current = null;
refreshSessions();
$("sessPane").classList.add("hidden");
$("noSess").classList.remove("hidden");
};
const sendKeys = (keys) => current && post(`/api/sessions/${current}/keys`, { keys }).catch(() => {});
$("keyEsc").onclick = () => sendKeys(["Escape"]);
$("keyUp").onclick = () => sendKeys(["Up"]);
$("keyDown").onclick = () => sendKeys(["Down"]);
$("keyEnter").onclick = () => sendKeys(["Enter"]);
$("keyCtrlC").onclick = () => sendKeys(["C-c"]);
$("modeBtn").onclick = () => current && post(`/api/sessions/${current}/mode`, { action: "cycle" }).catch(() => {});
$("promptForm").onsubmit = async (e) => {
e.preventDefault();
const t = $("promptText").value;
if (!t.trim() || !current) return;
try {
await post(`/api/sessions/${current}/prompt`, { text: t });
$("promptText").value = "";
} catch (err) { alert("Kunde inte skicka: " + err.message); }
};
$("promptText").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
$("promptForm").requestSubmit();
}
});
// ---------- delningar ----------
async function refreshShares() {
shares = (await api("/api/shares")) || [];
const box = $("shareItems");
box.innerHTML = "";
for (const sh of shares) {
const d = document.createElement("div");
d.className = "share-item" + (currentShare === sh.id ? " active" : "");
const when = new Date(sh.created).toLocaleString("sv-SE", { dateStyle: "short", timeStyle: "short" });
d.innerHTML = `<button class="del danger" title="ta bort">✕</button>
<div class="name">${escapeHtml(sh.name)}</div>
<div class="meta">${when}${sh.session ? " · " + escapeHtml(sh.session) : ""}${sh.note ? "<br>" + escapeHtml(sh.note) : ""}</div>`;
d.querySelector(".del").onclick = async (e) => {
e.stopPropagation();
await api(`/api/shares/${sh.id}`, { method: "DELETE" });
if (currentShare === sh.id) { currentShare = null; $("shareView").innerHTML = "Välj en delning i listan"; }
refreshShares();
};
d.onclick = () => viewShare(sh);
box.appendChild(d);
}
}
async function viewShare(sh) {
currentShare = sh.id;
refreshShares();
const v = $("shareView");
v.classList.remove("empty");
const raw = `/api/shares/${sh.id}/raw?token=${encodeURIComponent(token)}`;
const ext = sh.name.toLowerCase().split(".").pop();
if (["png", "jpg", "jpeg", "gif", "svg"].includes(ext)) {
v.innerHTML = `<img src="${raw}" alt="${escapeHtml(sh.name)}">`;
} else if (["html", "htm", "pdf"].includes(ext)) {
v.innerHTML = `<iframe src="${raw}" sandbox="allow-scripts"></iframe>`;
} else if (ext === "md") {
const txt = await api(`/api/shares/${sh.id}/raw`);
v.innerHTML = `<div class="md">${mdToHtml(txt)}</div>`;
} else {
const txt = await api(`/api/shares/${sh.id}/raw`);
v.innerHTML = `<pre>${escapeHtml(txt)}</pre>`;
}
}
// ---------- inställningar ----------
async function loadConfig() {
const c = await api("/api/config");
$("cfgCmd").value = c.agent_cmd || "";
$("cfgArgs").value = (c.agent_args || []).join(" ");
$("cfgWorkdir").value = c.workdir || "";
$("cfgPoll").value = c.poll_ms || 500;
$("cfgNtfyOn").checked = !!c.ntfy?.enabled;
$("cfgNtfyQ").checked = !!c.ntfy?.notify_on_question;
$("cfgNtfyServer").value = c.ntfy?.server || "";
$("cfgNtfyTopic").value = c.ntfy?.topic || "";
window._ntfyAllowed = c.ntfy?.allowed_topics || ["agent-helm", "claude"];
}
$("cfgForm").onsubmit = async (e) => {
e.preventDefault();
try {
await api("/api/config", {
method: "PUT",
body: JSON.stringify({
agent_cmd: $("cfgCmd").value.trim(),
agent_args: $("cfgArgs").value.trim() ? $("cfgArgs").value.trim().split(/\s+/) : [],
workdir: $("cfgWorkdir").value.trim(),
poll_ms: Number($("cfgPoll").value),
ntfy: {
enabled: $("cfgNtfyOn").checked,
notify_on_question: $("cfgNtfyQ").checked,
server: $("cfgNtfyServer").value.trim(),
topic: $("cfgNtfyTopic").value.trim(),
allowed_topics: window._ntfyAllowed,
},
}),
});
$("cfgStatus").textContent = "Sparat ✓";
setTimeout(() => ($("cfgStatus").textContent = ""), 2500);
} catch (err) { $("cfgStatus").textContent = "Fel: " + err.message; }
};
// ---------- hjälp ----------
function escapeHtml(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// Miniminimal markdown: rubriker, fet/kursiv, kod, listor, länkar, hr.
function mdToHtml(md) {
const blocks = md.split(/\n{2,}/);
return blocks.map((b) => {
if (/^```/.test(b)) return `<pre>${escapeHtml(b.replace(/^```\w*\n?|```$/g, ""))}</pre>`;
if (/^#{1,6} /.test(b)) {
const n = b.match(/^#+/)[0].length;
return `<h${n}>${inline(b.replace(/^#+ /, ""))}</h${n}>`;
}
if (/^([-*] |\d+\. )/m.test(b)) {
const items = b.split("\n").filter(Boolean).map((l) => `<li>${inline(l.replace(/^([-*]|\d+\.) /, ""))}</li>`).join("");
return /^\d/.test(b) ? `<ol>${items}</ol>` : `<ul>${items}</ul>`;
}
if (/^(-{3,}|_{3,}|\*{3,})$/.test(b.trim())) return "<hr>";
return `<p>${inline(b)}</p>`;
}).join("\n");
function inline(s) {
return escapeHtml(s)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/\n/g, "<br>");
}
}
// ---------- start ----------
if (token) tryConnect(); else showLogin();

110
helmd/web/index.html Normal file
View File

@@ -0,0 +1,110 @@
<!doctype html>
<html lang="sv">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b0e14">
<title>agent-helm</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='80' font-size='80'%3E⛵%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="login" class="login hidden">
<div class="login-card">
<h1>⛵ agent-helm</h1>
<p>Klistra in API-token från servern (<code>helmd token</code>).</p>
<input id="tokenInput" type="password" placeholder="token" autocomplete="off">
<button id="tokenSave" class="primary">Anslut</button>
<p id="loginError" class="error"></p>
</div>
</div>
<div id="app" class="app hidden">
<header>
<span class="brand">⛵ agent-helm</span>
<nav>
<button data-tab="sessions" class="tab active">Sessioner</button>
<button data-tab="shares" class="tab">Delningar<span id="shareBadge" class="badge hidden"></span></button>
<button data-tab="settings" class="tab">Inställningar</button>
</nav>
<span id="connDot" class="dot" title="SSE-anslutning"></span>
</header>
<main id="tab-sessions" class="tab-page">
<aside class="sess-list">
<div class="sess-head">
<span>Sessioner</span>
<button id="newSessBtn" title="Ny session"></button>
</div>
<div id="sessItems"></div>
<form id="newSessForm" class="hidden">
<input id="newSessName" placeholder="namn (t.ex. agy)" required>
<input id="newSessCmd" placeholder="kommando (tomt = agent från config)">
<input id="newSessCwd" placeholder="arbetskatalog (tomt = default)">
<div class="row">
<button type="submit" class="primary">Starta</button>
<button type="button" id="newSessCancel">Avbryt</button>
</div>
</form>
</aside>
<section class="sess-view">
<div id="noSess" class="empty">Ingen session vald — skapa en med </div>
<div id="sessPane" class="hidden">
<div class="sess-toolbar">
<span id="sessTitle"></span>
<span class="spacer"></span>
<button id="keyEsc" title="Escape">Esc</button>
<button id="keyUp" title="Pil upp"></button>
<button id="keyDown" title="Pil ned"></button>
<button id="keyEnter" title="Enter"></button>
<button id="keyCtrlC" title="Ctrl+C">^C</button>
<button id="modeBtn" title="Byt läge (Shift+Tab)">Läge</button>
<button id="killBtn" class="danger" title="Döda sessionen"></button>
</div>
<div id="questionCard" class="question hidden"></div>
<pre id="screen" class="screen"></pre>
<form id="promptForm">
<textarea id="promptText" rows="2" placeholder="Skriv prompt… (Enter skickar, Shift+Enter = ny rad)"></textarea>
<button type="submit" class="primary">Skicka</button>
</form>
</div>
</section>
</main>
<main id="tab-shares" class="tab-page hidden">
<div class="shares-wrap">
<div id="shareItems" class="share-list"></div>
<div id="shareView" class="share-view empty">Välj en delning i listan</div>
</div>
</main>
<main id="tab-settings" class="tab-page hidden">
<form id="cfgForm" class="cfg">
<h2>Agent</h2>
<label>Kommando <input id="cfgCmd"></label>
<label>Argument (mellanslagsseparerade) <input id="cfgArgs"></label>
<label>Arbetskatalog <input id="cfgWorkdir"></label>
<label>Poll-intervall (ms) <input id="cfgPoll" type="number" min="100" step="100"></label>
<h2>ntfy</h2>
<label class="check"><input id="cfgNtfyOn" type="checkbox"> ntfy aktiverat</label>
<label class="check"><input id="cfgNtfyQ" type="checkbox"> pusha vid ny fråga</label>
<label>Server <input id="cfgNtfyServer"></label>
<label>Topic <input id="cfgNtfyTopic"></label>
<div class="row">
<button type="submit" class="primary">Spara</button>
<span id="cfgStatus"></span>
</div>
<h2>Klient</h2>
<div class="row">
<button type="button" id="logoutBtn" class="danger">Glöm token (logga ut)</button>
<span id="verInfo" class="muted"></span>
</div>
</form>
</main>
</div>
<script type="module" src="app.js"></script>
</body>
</html>

17
helmd/web/manifest.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "agent-helm",
"short_name": "helm",
"description": "Fjärrstyr CLI-agenter (agy m.fl.) via helmd",
"start_url": "/",
"display": "standalone",
"background_color": "#0b0e14",
"theme_color": "#0b0e14",
"icons": [
{
"src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%230b0e14'/%3E%3Ctext x='50' y='72' font-size='60' text-anchor='middle'%3E⛵%3C/text%3E%3C/svg%3E",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}

165
helmd/web/style.css Normal file
View File

@@ -0,0 +1,165 @@
:root {
--bg: #0b0e14;
--bg2: #12161f;
--bg3: #1a2029;
--border: #262d3a;
--fg: #d6dbe5;
--muted: #8a93a5;
--accent: #4f9cf9;
--accent2: #7bc4ff;
--green: #3fca7c;
--red: #e5605c;
--amber: #e0a63f;
--radius: 10px;
font-size: 15px;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background: var(--bg); color: var(--fg);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.hidden { display: none !important; }
button {
background: var(--bg3); color: var(--fg); border: 1px solid var(--border);
border-radius: 7px; padding: 6px 12px; cursor: pointer; font: inherit;
}
button:hover { border-color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { background: var(--accent2); }
button.danger { color: var(--red); }
button.danger:hover { border-color: var(--red); }
input, textarea {
background: var(--bg2); color: var(--fg); border: 1px solid var(--border);
border-radius: 7px; padding: 8px 10px; font: inherit; width: 100%;
}
input:focus, textarea:focus { outline: none; border-color: var(--accent); }
code { background: var(--bg3); padding: 1px 5px; border-radius: 4px; }
.error { color: var(--red); min-height: 1.2em; }
.muted { color: var(--muted); }
/* ---- login ---- */
.login {
height: 100%; display: flex; align-items: center; justify-content: center;
}
.login-card {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 32px; width: min(420px, 92vw); display: flex; flex-direction: column; gap: 12px;
}
.login-card h1 { margin: 0 0 4px; font-size: 1.5rem; }
.login-card p { margin: 0; color: var(--muted); }
/* ---- app frame ---- */
.app { height: 100%; display: flex; flex-direction: column; }
header {
display: flex; align-items: center; gap: 16px;
padding: 10px 16px; background: var(--bg2); border-bottom: 1px solid var(--border);
}
.brand { font-weight: 600; }
nav { display: flex; gap: 4px; flex: 1; }
.tab { border: none; background: none; color: var(--muted); padding: 8px 12px; position: relative; }
.tab.active { color: var(--fg); }
.tab.active::after {
content: ""; position: absolute; left: 10px; right: 10px; bottom: -1px;
height: 2px; background: var(--accent); border-radius: 2px;
}
.badge {
background: var(--accent); color: #fff; border-radius: 9px;
font-size: 0.7rem; padding: 1px 6px; margin-left: 6px;
}
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--red); }
.dot.ok { background: var(--green); }
.tab-page { flex: 1; min-height: 0; display: flex; }
/* ---- sessions ---- */
.sess-list {
width: 240px; border-right: 1px solid var(--border); background: var(--bg2);
display: flex; flex-direction: column; padding: 10px; gap: 8px; overflow-y: auto;
}
.sess-head { display: flex; justify-content: space-between; align-items: center; color: var(--muted); }
.sess-item {
padding: 10px 12px; border-radius: 7px; cursor: pointer; border: 1px solid transparent;
display: flex; align-items: center; gap: 8px;
}
.sess-item:hover { background: var(--bg3); }
.sess-item.active { background: var(--bg3); border-color: var(--border); }
.sess-item .dot { width: 8px; height: 8px; flex: none; }
.sess-item .q { color: var(--amber); margin-left: auto; }
#newSessForm { display: flex; flex-direction: column; gap: 8px; }
.row { display: flex; gap: 8px; align-items: center; }
.sess-view { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--muted); }
#sessPane { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.sess-toolbar {
display: flex; gap: 6px; align-items: center; padding: 8px 12px;
border-bottom: 1px solid var(--border); flex-wrap: wrap;
}
.sess-toolbar .spacer { flex: 1; }
.sess-toolbar button { padding: 4px 9px; font-size: 0.85rem; }
#sessTitle { font-weight: 600; }
.screen {
flex: 1; margin: 0; padding: 12px; overflow: auto; background: #05070b;
font: 12.5px/1.35 ui-monospace, "JetBrains Mono", Consolas, monospace;
white-space: pre; color: #cfd6e4;
}
#promptForm { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border); }
#promptForm textarea { resize: vertical; }
/* ---- question card ---- */
.question {
margin: 10px 12px 0; padding: 14px; border: 1px solid var(--amber);
border-radius: var(--radius); background: color-mix(in srgb, var(--amber) 8%, var(--bg2));
}
.question h3 { margin: 0 0 10px; font-size: 0.95rem; }
.question .opts { display: flex; flex-direction: column; gap: 6px; }
.question .opts button {
text-align: left; white-space: normal;
}
.question .opts button.selected { border-color: var(--amber); }
.question .opts button:hover { border-color: var(--accent); }
/* ---- shares ---- */
.shares-wrap { flex: 1; display: flex; min-height: 0; }
.share-list {
width: 280px; border-right: 1px solid var(--border); background: var(--bg2);
overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 6px;
}
.share-item {
padding: 10px 12px; border-radius: 7px; cursor: pointer; border: 1px solid transparent;
}
.share-item:hover, .share-item.active { background: var(--bg3); border-color: var(--border); }
.share-item .name { font-weight: 600; word-break: break-all; }
.share-item .meta { color: var(--muted); font-size: 0.8rem; }
.share-item .del { float: right; }
.share-view { flex: 1; min-width: 0; overflow: auto; padding: 16px; }
.share-view iframe {
width: 100%; height: 100%; border: 1px solid var(--border); border-radius: 7px; background: #fff;
}
.share-view img { max-width: 100%; border-radius: 7px; }
.share-view pre {
background: var(--bg2); padding: 14px; border-radius: 7px; overflow-x: auto;
}
.md { max-width: 820px; line-height: 1.55; }
.md h1, .md h2, .md h3 { border-bottom: 1px solid var(--border); padding-bottom: 4px; }
.md pre { background: var(--bg2); padding: 12px; border-radius: 7px; overflow-x: auto; }
.md code { background: var(--bg3); }
.md a { color: var(--accent2); }
/* ---- settings ---- */
.cfg { max-width: 560px; padding: 24px; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; }
.cfg h2 { margin: 12px 0 2px; font-size: 1.05rem; }
.cfg label { display: flex; flex-direction: column; gap: 4px; color: var(--muted); font-size: 0.9rem; }
.cfg label.check { flex-direction: row; align-items: center; gap: 8px; }
.cfg input[type="checkbox"] { width: auto; }
/* ---- mobil ---- */
@media (max-width: 720px) {
.tab-page { flex-direction: column; }
.sess-list { width: 100%; max-height: 30vh; border-right: none; border-bottom: 1px solid var(--border); }
.shares-wrap { flex-direction: column; }
.share-list { width: 100%; max-height: 30vh; }
.screen { font-size: 11px; }
}