Step 1 in progress: the settings page server, probes, teaching pages and UI (not deployed yet)

- settings/server.py: read-only routes (overview, keys, computers, gear, companion, logs, history,
  help facts, key guide / cheat sheet / where things are), the editing-code check with a lockout,
  LAN-only binding with a per-minute rebind
- settings/probes.py: fixed read-only probes per computer; whole lists fetched and matched locally
- settings/keyguide.py: the generated teaching pages
- settings/ui: the page, every screen read-only
- status_panel.py: snapshot.json every 5 s for the page; the Companion database lookup prefers the
  running build's folder and skips a database that will not open (a stale v5.1 folder was breaking it)
- config.json: every computer's record (what runs there, network ports, done-by-hand ticks), the
  editing code (kept in the repo, the user's choice), an empty gear list
- install.sh: the settings page as a second LaunchAgent; settings/ and schema.py deployed

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 16:23:59 -04:00
co-authored by Claude Fable 5.1
parent b05ff408d5
commit 2343a27529
11 changed files with 2266 additions and 19 deletions
+316
View File
@@ -0,0 +1,316 @@
/* The settings page, step 1: every screen, read-only. Plain JS, no framework. */
(function () {
"use strict";
const main = document.getElementById("main");
const state = { config: null, schema: null, keys: null, editing: null, name: null };
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const api = async (path, opts) => {
const r = await fetch(path, opts);
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText);
return r.json();
};
const post = (path, body) => api(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) });
const ago = (s) => s == null ? "" : s < 90 ? Math.round(s) + " s ago" : s < 5400 ? Math.round(s / 60) + " min ago" : Math.round(s / 3600) + " h ago";
const when = (t) => t ? new Date(t * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) : "";
const hex = (n) => "#" + (n >>> 0).toString(16).padStart(6, "0").slice(-6);
const lightDot = (l) => `<i class="light ${esc(l || "unknown")}"></i>`;
const wordOf = (w) => `<span class="word ${esc((w || "").split(" ")[0])}">${esc(w)}</span>`;
// ---- top bar -------------------------------------------------------------
document.getElementById("legendbtn").onclick = () => { const l = document.getElementById("legend"); l.hidden = !l.hidden; };
const editbtn = document.getElementById("editbtn");
editbtn.onclick = () => {
if (state.editing) { state.editing = null; editbtn.textContent = "Editing: off"; editbtn.classList.remove("on"); return; }
dialog(`<h3>Switch Editing on</h3><p class="help">The editing code is in the campus's settings file in the repo; ask Chris. In this step nothing can be changed yet: the switch only proves the code works from here.</p>
<input id="dcode" placeholder="editing code" autocomplete="off"><input id="dname" placeholder="your first name" autocomplete="off">
<div class="row"><button class="primary" id="dok">Switch on</button><button id="dcancel">Cancel</button></div><p id="dmsg" class="help"></p>`, (box) => {
box.querySelector("#dcancel").onclick = closeDialog;
box.querySelector("#dok").onclick = async () => {
const code = box.querySelector("#dcode").value, name = box.querySelector("#dname").value.trim();
const r = await post("/api/editing/check", { code });
if (r.ok && name) { state.editing = code; state.name = name; editbtn.textContent = "Editing as " + name; editbtn.classList.add("on"); closeDialog(); }
else box.querySelector("#dmsg").textContent = r.locked_for ? `Locked for ${Math.ceil(r.locked_for / 60)} minutes after too many tries.` : r.no_code ? "No editing code is set in the config yet." : !name ? "Your first name, please." : `Not the code (${r.tries_left} tries left).`;
};
});
};
function dialog(html, wire) { const d = document.getElementById("dialog"); d.innerHTML = `<div class="box">${html}</div>`; d.hidden = false; wire(d.querySelector(".box")); }
function closeDialog() { document.getElementById("dialog").hidden = true; }
// ---- search --------------------------------------------------------------
const search = document.getElementById("search"), results = document.getElementById("searchresults");
search.oninput = () => {
const q = search.value.trim().toLowerCase();
if (!q || !state.config) { results.hidden = true; return; }
const hits = [];
const cfg = state.config;
for (const t of cfg.tiles) if ((t.label + " " + t.id).toLowerCase().includes(q)) hits.push([`Key ${t.label}`, `#keys/${t.id}`]);
for (const k of (cfg.restart_page || { keys: [] }).keys) if (k.label.toLowerCase().includes(q)) hits.push([`Restart key ${k.label}`, `#keys/restart/${k.id}`]);
for (const [a, h] of Object.entries(cfg.hosts || {})) if ((h.name || a).toLowerCase().includes(q) || (h.role || "").toLowerCase().includes(q)) hits.push([`Computer ${h.name || a}`, `#computers/${a}`]);
for (const [id, f] of Object.entries(cfg.fixes || {})) if ((id + " " + (f.label || "") + " " + (f.short || "")).toLowerCase().includes(q)) hits.push([`Fix ${f.short || id}: ${f.label || ""}`, `#fixes/${id}`]);
for (const [path, f] of Object.entries(state.schema ? state.schema.fields : {})) if (!path.includes("[]") && !path.includes("*") && (path + " " + f.help).toLowerCase().includes(q)) hits.push([`Setting ${path}`, `#site/${f.screen.toLowerCase().replace(/[^a-z]+/g, "-")}`]);
results.innerHTML = hits.slice(0, 20).map(([t, h]) => `<a href="${h}">${esc(t)}</a>`).join("") || `<a>Nothing called "${esc(q)}"</a>`;
results.hidden = false;
};
search.onblur = () => setTimeout(() => { results.hidden = true; }, 200);
// ---- routing -------------------------------------------------------------
const screens = { overview, keys, computers, logs, history, help, site, fixes };
let timer = null;
async function route() {
const [name, ...rest] = (location.hash || "#overview").slice(1).split("/");
for (const a of document.querySelectorAll(".menu a")) a.classList.toggle("active", a.getAttribute("href") === "#" + name);
clearInterval(timer); timer = null;
if (!state.config) {
const [c, s] = await Promise.all([api("/api/config"), api("/api/schema")]);
state.config = c.config; state.schema = s;
document.getElementById("sitename").textContent = (state.config.site || {}).name || "";
}
const fn = screens[name] || overview;
try { await fn(rest); } catch (e) { main.innerHTML = `<p class="notice">Could not load this screen: ${esc(e.message)}</p>`; }
if (name === "overview" || name === "computers") timer = setInterval(() => fn(rest, true).catch(() => {}), 5000);
}
window.addEventListener("hashchange", route);
route();
// ---- Overview ------------------------------------------------------------
async function overview(_, quiet) {
const o = await api("/api/overview");
const dot = document.getElementById("hbdot");
dot.className = "dot " + (o.heartbeat_age != null && o.heartbeat_age < 15 ? "ok" : "bad");
const prob = o.problems.length ? o.problems.map((p) => `<li><span class="lvl" style="background:${p.level === "err" ? "var(--problem)" : "var(--warn)"}"></span><b>${esc(p.text)}</b>${p.acked ? ' <span class="muted">(acknowledged)</span>' : ""} <span class="muted">— ${esc(p.action)}</span> <a href="#keys/${esc(p.key)}" class="small">open the key</a></li>`).join("") : `<li class="muted">Everything is fine.</li>`;
const needs = o.needs.length ? o.needs.map((n) => `<div class="notice grey">Needs Chris: ${esc(n)}</div>`).join("") : "";
const live = o.live && o.live.live ? `<div class="notice">Live now: ${esc(o.live.reason)}. Changes to the panel wait.</div>` : "";
const comps = o.computers.map((c) => `<tr class="click" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}</td><td class="muted small">${esc(c.reason)}</td></tr>`).join("");
const w = o.watchdog;
main.innerHTML = `
<h1>${o.problems.length ? o.problems.length + " problem" + (o.problems.length > 1 ? "s" : "") + " in the room" : "Everything is fine"}</h1>
<p class="what">Is the room OK, and what do I do. Problems mirror the red keys; hold the key, or open it for the details.</p>
<ul class="problems">${prob}</ul>${needs}${live}
<div class="cols">
<div><h2>Live deck <span class="muted small">${o.deck ? "as the Stream Deck looks now · click a key" : "(Companion is not answering)"}</span></h2>${deckHtml(o.deck, state.config, "status")}
<div class="row" style="margin-top:10px"><button disabled title="step 4">Change the playlist</button><button disabled title="step 4">Add or remove a HyperDeck</button><button disabled title="step 4">Add a MIDI link</button></div></div>
<div><h2>Computers</h2><div class="tablewrap"><table>${comps}</table></div></div>
</div>
<div class="card">
<dl class="kv">
<dt>Watchdog</dt><dd>${w.running ? "OK, running" : "<b>not running</b>"}${w.since ? " since " + esc(when(w.since)) : ""}${w.version ? ` <span class="pill">code ${esc(w.version)}</span>` : ""}${o.heartbeat_age != null ? ` · heartbeat ${esc(ago(o.heartbeat_age))}` : ""}</dd>
<dt>Companion</dt><dd>${o.companion_ok ? "OK" : "<b>not answering</b>"}${o.pages.status ? (o.pages.status.ok ? ", pages OK" : ", page " + o.pages.status.page + " is called " + esc(o.pages.status.live || "?")) : ""}</dd>
<dt>Alarms</dt><dd>${o.service_on ? "ON" : "silenced until midnight"}${o.alarms_override != null ? " (by hand)" : ""} <span class="muted small">· ${esc(o.phrases.alarms)}</span></dd>
<dt>Auto-fix</dt><dd>${esc(o.auto_fix.phrase)}${o.auto_fix.state && o.auto_fix.state.on ? " · <b>may act now</b>" : ""}${o.auto_fix.state && o.auto_fix.state.paused ? " (waiting out a network blip)" : ""}</dd>
<dt>Running now</dt><dd>${Object.keys(o.running || {}).length ? Object.values(o.running).map((r) => esc(r.what + " on " + r.key)).join(", ") : "no fix is running"}</dd>
<dt>Last change</dt><dd>${o.last_change ? esc(o.last_change.when + " · " + o.last_change.note) : "—"} <a href="#history" class="small">History</a></dd>
</dl>
<div class="row" style="margin-top:10px"><button disabled title="step 2">Restart the watchdog</button><button disabled title="step 2">Silence alarms until midnight</button></div>
</div>`;
if (!quiet) window.scrollTo(0, 0);
}
function deckHtml(deck, cfg, which, selected) {
const cells = {};
const rp = cfg.restart_page;
if (which === "restart" && rp) { for (const k of rp.keys) cells[k.pos] = { id: k.id, label: k.label }; cells[rp.back_pos] = { nav: "STATUS\nPAGE" }; }
else { for (const t of cfg.tiles) if (t.pos) cells[t.pos] = { id: t.id, label: t.label }; if (rp) cells[rp.jump_pos] = { nav: "RESTART\nPAGE" }; }
const nav = { "0,0": "UP", "1,0": String(which === "restart" && rp ? rp.page : cfg.page), "2,0": "DOWN" };
let out = `<div class="deck">`;
for (let r = 0; r < 4; r++) for (let c = 0; c < 8; c++) {
const k = `${r},${c}`, cell = cells[k];
if (nav[k]) out += `<div class="key nav">${esc(nav[k])}</div>`;
else if (cell && cell.nav) out += `<div class="key nav">${esc(cell.nav)}</div>`;
else if (cell) {
const s = deck && deck[cell.id];
const style = s ? `--b1:${hex(s.b)};--f1:${hex(s.f)};--b2:${hex(s.b2)};--f2:${hex(s.f2)};background:${hex(s.b)};color:${hex(s.f)}` : "background:#444;color:#ddd";
const blink = s && s.b !== s.b2 ? " blink" : "";
out += `<div class="key${blink}${selected === cell.id ? " sel" : ""}" style="${style}" onclick="location.hash='#keys/${which === "restart" ? "restart/" : ""}${esc(cell.id)}'">${esc(s ? s.x : cell.label)}</div>`;
} else out += `<div class="key empty"></div>`;
}
return out + `</div>`;
}
// ---- Keys ----------------------------------------------------------------
async function keys(rest) {
const k = state.keys || (state.keys = await api("/api/keys"));
const o = await api("/api/overview").catch(() => ({ deck: null }));
const restart = rest[0] === "restart";
const sel = restart ? rest[1] : rest[0];
const tabs = `<div class="tabs"><button class="${restart ? "" : "on"}" onclick="location.hash='#keys'">Status page (page ${k.page})</button>${k.restart_page ? `<button class="${restart ? "on" : ""}" onclick="location.hash='#keys/restart'">Restart page (page ${k.restart_page.page})</button>` : ""}</div>`;
let panel = `<p class="muted">Click a key to see what it watches and what holding it does.</p>`;
if (sel && !restart) {
const t = k.tiles.find((x) => x.id === sel);
if (t) panel = keyPanel(t, k);
} else if (sel && restart) {
const r = k.restart_page.keys.find((x) => x.id === sel);
if (r) panel = `<div class="card"><h2>${esc(r.label)}</h2><p class="muted">row ${r.pos[0]}, column ${r.pos[1]}</p><p><b>HOLDING ${esc(k.hold.replace("HOLD ", ""))} WILL:</b> ${esc(r.holding)}, even when the Status page says it is fine.</p>${r.watch ? `<p class="small muted">Keeps flashing HOLD TO FIX while down: ${esc(r.watch.join(", "))}</p>` : ""}<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd><dt>fix</dt><dd class="mono">${esc(r.fix)}</dd></dl></details><p class="help">Editing arrives with step 2.</p></div>`;
}
const tray = k.tiles.filter((t) => !t.pos && t.kind !== "master").map((t) => `<button onclick="location.hash='#keys/${esc(t.id)}'">${esc(t.label)}</button>`).join(" ");
main.innerHTML = `<h1>Keys</h1><p class="what">The deck as people hold it. In this step you can look; moving and editing keys arrive with steps 2 and 3.</p>${tabs}
<div class="cols"><div>${deckHtml(o.deck, state.config, restart ? "restart" : "status", sel)}${!restart && tray ? `<p class="small muted" style="margin-top:8px">Shown inside their group, no key of their own: ${tray}</p>` : ""}</div><div>${panel}</div></div>`;
}
function keyPanel(t, k) {
const cfg = state.config;
const checks = (t.checks || []).map((c, i) => `<li>${esc(c.sentence)} <details><summary>Advanced</summary><dl class="kv small">${Object.entries(c).filter(([n]) => !["sentence", "identity"].includes(n)).map(([n, v]) => `<dt>${esc(n)}</dt><dd class="mono">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details></li>`).join("");
const members = t.members ? `<p><b>Sums up:</b> ${t.members.map((m) => `<a href="#keys/${esc(m)}">${esc((cfg.tiles.find((x) => x.id === m) || {}).label || m)}</a>`).join(", ")}</p>` : "";
return `<div class="card"><h2>${esc(t.label)}</h2><p class="muted">${t.pos ? `row ${t.pos[0]}, column ${t.pos[1]}` : "inside its group"}${t.kind ? ` · ${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}` : ""}</p>
${t.says && t.says.length ? `<p><b>WHAT THIS KEY WILL SAY:</b> <span class="mono">${esc(t.says.join(", "))}</span></p>` : ""}
<p><b>HOLDING ${esc(k.hold.replace("HOLD ", ""))} WILL:</b> ${esc(t.holding)}${t.hold_always ? " <span class='pill'>also when green</span>" : ""}</p>
${members}${checks ? `<p><b>Checks:</b></p><ol>${checks}</ol>` : ""}
<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(t.id)}</dd></dl></details><p class="help">Editing arrives with step 2.</p></div>`;
}
// ---- Computers -----------------------------------------------------------
let compTab = "map";
async function computers(rest, quiet) {
const m = await api("/api/machines");
const selected = rest[0] && rest[0] !== "gear" && rest[0] !== "setup" ? rest[0] : null;
if (rest[0] === "gear" || rest[0] === "setup") compTab = rest[0]; else if (!quiet || !compTab) compTab = selected ? "map" : compTab || "map";
const tabs = `<div class="tabs"><button class="${compTab === "map" ? "on" : ""}" onclick="location.hash='#computers'">Map</button><button class="${compTab === "gear" ? "on" : ""}" onclick="location.hash='#computers/gear'">Gear</button><button class="${compTab === "setup" ? "on" : ""}" onclick="location.hash='#computers/setup'">Setup</button>
<button id="probe" ${m.probing ? "disabled" : ""}>${m.probing ? "Looking…" : "Check computers now"}</button></div>`;
let body = "";
if (compTab === "gear") body = await gearHtml();
else if (compTab === "setup") body = setupHtml(m);
else {
const rows = m.computers.slice().sort((a, b) => (a.word === "Problem" ? 0 : 1) - (b.word === "Problem" ? 0 : 1)).map((c) => `<tr class="click${selected === c.alias ? " sel" : ""}" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}${c.self ? ' <span class="muted small">(this Mac)</span>' : ""}</td><td class="small">${esc(c.role)}</td><td class="small">${c.keys.map((k) => `<span class="pill">${esc((state.config.tiles.find((t) => t.id === k) || { label: k }).label)}</span>`).join(" ")}</td><td class="muted small">${esc(c.reason)}${c.looked ? `<br>looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}</td></tr>`).join("");
body = `<div class="tablewrap"><table><tr><th>status</th><th>computer</th><th>what it does for the panel</th><th>keys that turn red if it dies</th><th></th></tr>${rows}</table></div>`;
const c = m.computers.find((x) => x.alias === selected);
if (c) body += computerCard(c);
}
main.innerHTML = `<h1>Computers</h1><p class="what">What runs where. One row per computer; click it for everything that runs there, with a light each.</p>${tabs}${body}`;
const pb = document.getElementById("probe");
if (pb) pb.onclick = async () => { pb.disabled = true; pb.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => computers(rest, true), 4000); };
}
function computerCard(c) {
const items = c.items.map((i) => `<tr><td>${lightDot(i.light)}${esc(i.light === "ok" ? "OK" : i.light === "problem" ? "Problem" : i.light === "not_needed" ? "Not needed" : "Can't see from here")}</td><td>${esc(i.name)}${i.watched === false ? ' <span class="muted small">(not watched)</span>' : ""}</td><td class="muted small">${esc(i.reason || i.detail || (i.detail && JSON.stringify(i.detail)) || "")}</td></tr>`).join("");
const found = c.found || {};
const foundHtml = Object.entries(found).filter(([, v]) => Array.isArray(v) && v.length).map(([k, v]) => `<p class="small"><b>${esc(k)}:</b> <span class="mono">${esc(typeof v[0] === "object" ? v.map((x) => x.iface + " " + x.ip + (x.active ? "" : " (down)")).join(", ") : v.join(", "))}</span></p>`).join("") || `<p class="muted small">Press "Check computers now" to look.</p>`;
const done = Object.entries(c.manual_done || {}).map(([k, v]) => `<li>${lightDot("hand")}${esc(k.replace(/_/g, " "))}: done by hand (${esc(v.by)}, ${esc(v.on)})</li>`).join("");
return `<div class="card" id="card"><h2>${esc(c.name)} ${wordOf(c.word)}</h2><p class="muted">${esc(c.role)}${c.ssh ? ` · logs in as <span class="mono">${esc(c.ssh)}</span>` : ""}${c.mac ? ` · wake-on-LAN ${esc(c.mac)}` : ""}${c.pingable === false ? " · does not answer pings" : c.ping != null ? ` · ping ${c.ping ? "OK" : "<b>no answer</b>"}` : ""}${c.error ? ` · <b>${esc(c.error)}</b>` : ""}</p>
${c.notes ? `<p class="notice grey">${esc(c.notes)}</p>` : ""}
<h3>What runs here</h3><div class="tablewrap"><table>${items}</table></div>
${done ? `<ul class="small" style="list-style:none;padding:0">${done}</ul>` : ""}
<details><summary>Everything found on this computer</summary>${foundHtml}</details>
${c.self ? `<details><summary>This Mac's set-up lights</summary><dl class="kv small">${Object.entries(c.setup || {}).map(([k, v]) => `<dt>${esc(k.replace(/_/g, " "))}</dt><dd>${typeof v === "boolean" ? lightDot(v ? "ok" : "problem") + (v ? "OK" : "Problem") : esc(JSON.stringify(v))}</dd>`).join("")}</dl></details>` : ""}
</div>`;
}
async function gearHtml() {
const g = await api("/api/gear");
return `<p class="small muted">Devices that are not computers, from the keys that watch them. Where each one lives is typed once on this list (step 2).</p><div class="tablewrap"><table><tr><th>light</th><th>device</th><th>kind</th><th>address</th><th>where / notes</th><th>watched by</th></tr>
${g.map((r) => `<tr><td>${lightDot(r.light)}${esc(r.light === "ok" ? "OK" : r.light === "problem" ? "Problem" : "–")}</td><td>${esc(r.name)}</td><td>${esc(r.kind)}</td><td class="mono small">${esc(r.address || "")}${r.detail ? `<br><span class="muted">${esc(r.detail)}</span>` : ""}</td><td class="small">${esc(r.location || "")} ${esc(r.notes || "")} <span class="muted">${esc(r.reason || "")}</span></td><td class="small">${r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc((state.config.tiles.find((t) => t.id === k) || { label: k }).label)}</a>`).join(", ")}</td></tr>`).join("")}</table></div>`;
}
function setupHtml(m) {
return m.computers.map((c) => {
const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac";
const steps = m.setup_steps[kind];
const lightFor = (step) => {
const s = c.setup || {};
if (/login key/.test(step)) return s.key_ok === true || s.login_key === true ? "ok" : s.key_ok === false || s.login_key === false ? "problem" : "unknown";
if (/python/.test(step)) return s.python_ok === true ? "ok" : s.python_ok === false ? "problem" : "unknown";
if (/SSH service/.test(step)) return c.reachable ? "ok" : "problem";
if (/firewall/.test(step)) return s.firewall_allow ? "ok" : "unknown";
if (/wake-on-LAN/.test(step)) return s.wake_on_lan === "Enabled" ? "ok" : s.wake_on_lan ? "problem" : "unknown";
if (/Public/.test(step)) return s.profile === "Public" ? "ok" : s.profile ? "problem" : "unknown";
if (/DVS helper/.test(step)) { const it = (c.items || []).find((i) => i.kind === "helper_dvs"); return it ? it.light : "not_needed"; }
if (/Pro Tools helper/.test(step)) { const it = (c.items || []).find((i) => i.kind === "helper_solo"); return it ? it.light : "not_needed"; }
if (/StatusPanel/.test(step)) { const ts = (c.items || []).filter((i) => i.kind === "task"); return ts.length ? (ts.every((i) => i.light === "ok") ? "ok" : "problem") : "unknown"; }
if (/rtpMIDI/.test(step)) { const it = (c.items || []).find((i) => i.kind === "service"); return it ? it.light : "not_needed"; }
if (/custom variables/.test(step)) return "unknown";
if (/Netgear/.test(step)) return s.netgear_keychain === true ? "ok" : s.netgear_keychain === false ? "problem" : "not_needed";
if (/Google Drive/.test(step)) { const it = (c.items || []).find((i) => i.kind === "folder"); return it ? it.light : "not_needed"; }
if (/watchdog and the settings/.test(step)) { const ok = (c.items || []).filter((i) => i.kind === "launchagent" && /watchdog|settings/.test(i.name)); return ok.length && ok.every((i) => i.light === "ok") ? "ok" : "unknown"; }
if (/Wi-Fi/.test(step)) return c.manual_done && c.manual_done.wifi_power ? "hand" : "unknown";
if (/BIOS/.test(step)) return c.manual_done && c.manual_done.bios_wol ? "hand" : "unknown";
return "unknown";
};
return `<div class="card"><h3>${esc(c.name)}</h3><ul style="list-style:none;padding:0;margin:0">${steps.map((s) => { const l = lightFor(s); return `<li>${lightDot(l)}${esc(s)} <span class="muted small">${l === "hand" ? "(done by hand)" : l === "unknown" ? "– can't see from here" : ""}</span></li>`; }).join("")}</ul><p class="help">Generated set-up scripts and the [how] steps arrive with step 5.</p></div>`;
}).join("");
}
// ---- Logs ----------------------------------------------------------------
let logTab = "watchdog", logFilter = "";
async function logs(rest) {
logTab = rest[0] || logTab;
const tabs = ["watchdog", "errors", "autofix", "settings"].map((n) => `<button class="${logTab === n ? "on" : ""}" onclick="location.hash='#logs/${n}'">${{ watchdog: "Watchdog", errors: "Errors", autofix: "Auto-fix days", settings: "Settings page" }[n]}</button>`).join("");
const filters = logTab === "watchdog" ? `<div class="tabs">${["", "faults", "holds", "fixes", "auto", "errors"].map((f) => `<button class="${logFilter === f ? "on" : ""}" data-f="${f}">${f || "everything"}</button>`).join("")}</div>` : "";
let data;
if (logTab === "autofix") { data = await api("/api/logs/autofix" + (rest[1] ? "?file=" + encodeURIComponent(rest[1]) : "")); }
else data = await api(`/api/logs/${logTab}?filter=${encodeURIComponent(logFilter)}`);
const files = logTab === "autofix" ? `<p class="small">${(data.files || []).slice().reverse().map((f) => `<a href="#logs/autofix/${encodeURIComponent(f)}" class="${data.file === f ? "pill" : ""}">${esc(f.replace(" Auto-Fix.log", ""))}</a>`).join(" · ") || "no auto-fix days yet"}</p>` : "";
main.innerHTML = `<h1>Logs</h1><p class="what">What the watchdog did and saw. Newest lines at the bottom.</p><div class="tabs">${tabs}</div>${filters}${files}<pre class="log">${esc((data.lines || []).join("\n")) || "(empty)"}</pre>`;
for (const b of main.querySelectorAll("[data-f]")) b.onclick = () => { logFilter = b.dataset.f; logs(rest); };
const pre = main.querySelector("pre.log"); pre.scrollTop = pre.scrollHeight;
}
// ---- History -------------------------------------------------------------
async function history() {
const h = await api("/api/history");
main.innerHTML = `<h1>History</h1><p class="what">Every version of the settings that was ever applied. ${esc(h.note)}</p><div class="tablewrap"><table><tr><th>when</th><th>file</th><th>size</th></tr>${h.entries.map((e) => `<tr><td>${esc(e.when)}</td><td class="mono small">${esc(e.file)}</td><td>${esc(e.size)}</td></tr>`).join("") || "<tr><td colspan=3 class='muted'>nothing yet</td></tr>"}</table></div>`;
}
// ---- Help ----------------------------------------------------------------
async function help(rest) {
const facts = await api("/api/facts");
const gl = state.schema.glossary;
const cards = [
["Read a red key", "Green is fine. A flashing red key names the problem. Press it once to acknowledge (it stops flashing, stays dark red). Hold it " + esc(state.schema.fields.page_hold_ms ? "2 s" : "2 s") + " and it runs the fix for what is failing."],
["Purple means the watchdog is down", "Every key turns purple when the watchdog on the Ingest Mac has been silent for 15 s. Open this page: if the Overview says Companion is not answering, the problem is Companion; otherwise restart the watchdog (step 2 adds the button; until then: ask Chris)."],
["Which computer or device is that?", "Computers › Map shows every computer and the keys that turn red if it dies; Computers › Gear lists the switcher, decks, recorders, wireless and LED gear with their addresses."],
["What is a connection?", "Companion's link to one device (a HyperDeck, the Tascam, the switcher). The keys watch those connections; Site setup › Companion lists them all and which key watches each."],
].map(([t, b]) => `<div class="card"><h3>${t}</h3><p>${b}</p></div>`).join("");
const glossary = Object.entries(gl).filter(([g]) => !/while you hold/.test(g)).map(([g, words]) => `<details open><summary><b>${esc(g)}</b></summary><p class="mono small">${esc(words.join(" · "))}</p></details>`).join("");
main.innerHTML = `<h1>Help</h1><p class="what">Start with the four cards. The pages below are made from the settings, so they are never out of date.</p><div class="cards">${cards}</div>
<h2>Pages</h2><p><a href="/key-guide.html" target="_blank">What each key does</a> · <a href="/cheat.html" target="_blank">Cheat sheet (one page)</a> · <a href="/where.html" target="_blank">Where things are</a></p>
<h2>First-day walkthrough (10 minutes, no code needed)</h2><ol><li>Overview: the live deck. Green, red, purple.</li><li>Click a red key (or any key): read what it checks and what holding it will do.</li><li>Logs › Watchdog, filter "holds": find a hold and what it did.</li><li>Keys › Restart page: each key restarts one thing, even when green.</li><li>Overview: when alarms are on and when auto-fix may act by itself.</li><li>Computers › Map: which computer a key talks to, and what runs there.</li><li>Help › What each key does: the whole deck in words.</li></ol>
<h2>The words a key can show</h2>${glossary}
<h2>Things only a person can do</h2><ul><li>Run the set-up script on a new computer (ARD on a Mac, PowerShell as administrator on a PC), allow Accessibility on the Studio B Mac, click Allow on the Spotify prompt once.</li><li>Create Companion's custom variables and the blink trigger, add pages, install modules, name the Stream Decks.</li><li>Type the Netgear login into the keychain; keep Google Drive signed in for the saved switcher files.</li><li>Save a new switcher file from ATEM Software Control when the show changes.</li></ul>
<h2>Companion facts (found by trying)</h2><dl class="kv">${facts.facts.map(([q, a]) => `<dt>${esc(q)}</dt><dd>${esc(a)}</dd>`).join("")}</dl>`;
}
// ---- Site setup (read-only) ----------------------------------------------
async function site(rest) {
const cfg = state.config, F = state.schema.fields;
const sections = { site: "Site", schedules: "Schedules", companion: "Companion", "network-dante": "Network & Dante", spotify: "Spotify", atem: "ATEM" };
const which = sections[rest[0]] ? rest[0] : "site";
const tabs = Object.entries(sections).map(([k, n]) => `<button class="${which === k ? "on" : ""}" onclick="location.hash='#site/${k}'">${n}</button>`).join("");
const screen = sections[which];
const get = (path) => path.split(".").reduce((o, p) => (o && o[p] !== undefined ? o[p] : undefined), cfg);
const fieldHtml = (path, f) => { const v = get(path); return `<div class="field"><label>${esc(path)} <span class="help">${esc(f.help)}</span></label><div class="val">${v === undefined ? `<span class="muted">(default: ${esc(JSON.stringify(f.default))})</span>` : esc(typeof v === "object" ? JSON.stringify(v) : String(v))}</div></div>`; };
const own = Object.entries(F).filter(([p, f]) => f.screen === screen && !p.includes("[]") && !p.includes("*") && !["tiles", "fixes", "hosts", "gear"].includes(p));
let body = `<div class="card"><h3>Settings</h3>${own.filter(([, f]) => !f.advanced).map(([p, f]) => fieldHtml(p, f)).join("")}<details><summary>Advanced</summary>${own.filter(([, f]) => f.advanced).map(([p, f]) => fieldHtml(p, f)).join("")}</details></div>`;
if (which === "companion") {
const c = await api("/api/companion");
const lights = Object.entries(c.lights).map(([k, v]) => `<li>${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}</li>`).join("");
const conns = c.connections.map((r) => `<tr><td>${lightDot(r.category === "good" ? "ok" : r.enabled ? "problem" : "not_needed")}${esc(r.label)}</td><td class="mono small">${esc(r.module || "")}</td><td class="small">${esc(r.enabled ? r.category || "" : "off")}${r.message ? ` <span class="muted">${esc(r.message)}</span>` : ""}</td><td class="small">${r.watched_by.length ? r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc(k.toUpperCase())}</a>`).join(", ") : '<span class="muted">not watched</span>'}</td></tr>`).join("");
body = `<div class="card"><h3>Companion ${c.ok ? "OK" : "<b>not answering</b>"}${c.build ? ` <span class="pill">${esc(c.build)}</span>` : ""}</h3><p class="small muted">Set up once by the person who installed Companion. You normally only come here to see which connection a key watches.</p><ul style="list-style:none;padding:0">${lights}</ul>
${c.missing.length ? `<p class="notice">Keys watch connections Companion does not have: ${esc(c.missing.join(", "))}</p>` : ""}
<p class="small">Page-number buttons coloured on ${c.page_number_buttons.pages_coloured} pages (${esc(c.page_number_buttons.recorded_in)}).</p>
<h3>Connections (live)</h3><div class="tablewrap"><table><tr><th>connection</th><th>module</th><th>status</th><th>watched by</th></tr>${conns}</table></div></div>` + body;
}
if (which === "network-dante") {
const d = (cfg.tiles.flatMap((t) => t.checks || []).find((c) => c.type === "dante") || {}).devices || {};
const snap = await api("/api/snapshot");
const map = snap.dante_map || {};
body += `<div class="card"><h3>Dante device list (the DANTE key's devices)</h3><div class="tablewrap"><table><tr><th>Dante name</th><th>key text</th><th>address</th><th>switch / port</th><th>PoE</th></tr>${Object.entries(d).map(([n, a]) => { const e = map[n] || {}; return `<tr><td class="mono small">${esc(n)}</td><td>${esc(a)}</td><td class="mono small">${esc(e.ip || "")}</td><td class="small">${e.switch ? esc(e.switch + " port " + e.port) : ""}</td><td>${e.poe ? "yes" : ""}</td></tr>`; }).join("")}</table></div><p class="small muted">Map from the last survey ${snap.dante_map_age != null ? esc(ago(snap.dante_map_age)) : "(none yet)"}.</p></div>`;
}
if (which === "schedules") {
const ph = await api("/api/overview").then((o) => o.phrases).catch(() => ({}));
body = `<div class="notice">${esc(ph.alarms || "")} Auto-fix ${esc(ph.auto_fix || "")}. ${esc(((ph.live || "")[0] || "").toUpperCase() + (ph.live || "").slice(1))}.</div><div class="card"><h3>Fixes auto-fix may run</h3><p>${(cfg.auto_fix.fixes || []).map((f) => `<span class="pill">${esc((cfg.fixes[f] || {}).short || f)}</span>`).join(" ")}</p></div>` + body;
}
main.innerHTML = `<h1>Site setup</h1><p class="what">Set up once. Each section opens on the few things a person changes; the numbers sit under Advanced. Editing arrives with step 2.</p><div class="tabs">${tabs}</div>${body}`;
}
// ---- Fixes ---------------------------------------------------------------
async function fixes(rest) {
const cfg = state.config, auto = new Set(cfg.auto_fix.fixes || []);
const used = {};
for (const t of cfg.tiles) for (const c of t.checks || []) { const fx = typeof c.fix === "object" && c.fix ? Object.values(c.fix) : [c.fix]; for (const f of fx) if (f) (used[f] = used[f] || new Set()).add(t.label); if (t.hold_always) (used[t.hold_always] = used[t.hold_always] || new Set()).add(t.label); }
for (const k of (cfg.restart_page || { keys: [] }).keys) (used[k.fix] = used[k.fix] || new Set()).add("restart key " + k.label);
const rows = Object.entries(cfg.fixes).map(([id, f]) => `<tr class="click${rest[0] === id ? " sel" : ""}" onclick="location.hash='#fixes/${esc(id)}'"><td><b>${esc(f.short || id)}</b></td><td>${esc(f.label || "")}</td><td class="small">${esc(f.kind || "ssh")}${f.host ? " on " + esc((cfg.hosts[f.host] || {}).name || f.host) : f.kind === "local" || f.kind === "spotify" ? " on this Mac" : ""}</td><td class="small">${[...(used[id] || [])].join(", ")}</td><td class="small">${auto.has(id) ? "may run by itself" : f.never_auto ? "never by itself" : "hold only"}</td></tr>`).join("");
const f = cfg.fixes[rest[0]];
const detail = f ? `<div class="card"><h2>${esc(f.short || rest[0])}</h2><p>${esc(f.label || "")}</p><details open><summary>Advanced</summary><dl class="kv">${Object.entries(f).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details><p class="help">Builders (fixes made from fields instead of commands) arrive with step 2.</p></div>` : "";
main.innerHTML = `<h1>Fixes</h1><p class="what">Everything a hold can run. ${Object.keys(cfg.fixes).length} today.</p><div class="tablewrap"><table><tr><th>key text</th><th>plain words</th><th>built as</th><th>used by</th><th>by itself</th></tr>${rows}</table></div>${detail}`;
}
function alertBox(msg) { dialog(`<p>${esc(msg)}</p><button id="dok" class="primary">OK</button>`, (b) => { b.querySelector("#dok").onclick = closeDialog; }); }
})();
+44
View File
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Status Panel</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header class="top">
<div class="brand"><span class="dot" id="hbdot" title="watchdog heartbeat"></span><b>STATUS PANEL</b> <span id="sitename" class="muted"></span></div>
<input id="search" type="search" placeholder="find a key, computer, device, fix or setting" aria-label="search" autocomplete="off">
<div class="topright">
<button class="link" id="legendbtn" title="What the status words mean">?</button>
<button id="editbtn" class="toggle">Editing: off</button>
</div>
</header>
<div id="legend" class="legend" hidden>
<b>Status words, the same everywhere:</b>
<span><i class="l-ok"></i>OK</span>
<span><i class="l-problem"></i>Problem (with the reason)</span>
<span><i class="l-unknown"></i>Can't see from here – ask at the computer</span>
<span><i class="l-none"></i>Not needed</span>
<span><i class="l-hand"></i>Done by hand (who, when)</span>
</div>
<div id="searchresults" class="searchresults" hidden></div>
<div class="shell">
<nav class="menu" id="menu">
<a href="#overview">Overview</a>
<a href="#keys">Keys</a>
<a href="#computers">Computers</a>
<a href="#logs">Logs</a>
<a href="#history">History</a>
<a href="#help">Help</a>
<div class="divider">Set up once</div>
<a href="#site">Site setup</a>
<a href="#fixes">Fixes</a>
</nav>
<main id="main"><p class="muted">Loading…</p></main>
</div>
<div id="dialog" class="dialog" hidden></div>
<script src="/app.js"></script>
</body>
</html>
+73
View File
@@ -0,0 +1,73 @@
/* Layout: a fixed top bar, a narrow menu on the left, the screen on the right; one column on a phone. */
:root {
--bg: #f4f6f3; --surface: #ffffff; --fg: #1d2420; --muted: #5d6963; --line: #d5dcd6; --accent: #0f6b3a; --accent-soft: #e1f0e7;
--deck: #1b1f1d; --ok: #007a2e; --problem: #d8322b; --warn: #e3a000; --unknown: #8b96a0; --none: #c3c9c3; --hand: #2b6cb0; --purple: #6a1b9a;
--font: -apple-system, "Helvetica Neue", Arial, sans-serif; --mono: ui-monospace, Menlo, monospace;
}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--bg: #14181a; --surface: #1d2326; --fg: #e6ebe6; --muted: #9aa69e; --line: #333c3f; --accent: #5fcf8a; --accent-soft: #1f3a2b;
--deck: #0c0e0d; --ok: #3fbf6f; --problem: #ff6a5e; --warn: #f0b84a; --unknown: #8b96a0; --none: #4c5553; --hand: #7fb3ef; color-scheme: dark } }
:root[data-theme="dark"] {
--bg: #14181a; --surface: #1d2326; --fg: #e6ebe6; --muted: #9aa69e; --line: #333c3f; --accent: #5fcf8a; --accent-soft: #1f3a2b;
--deck: #0c0e0d; --ok: #3fbf6f; --problem: #ff6a5e; --warn: #f0b84a; --unknown: #8b96a0; --none: #4c5553; --hand: #7fb3ef; color-scheme: dark }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 var(--font); }
.top { position: sticky; top: env(safe-area-inset-top, 0px); z-index: 5; display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: var(--surface); border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.brand b { letter-spacing: .06em; }
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--unknown); display: inline-block; }
.dot.ok { background: var(--ok); } .dot.bad { background: var(--purple); }
#search { flex: 1; min-width: 0; padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--fg); font: inherit; }
.topright { display: flex; gap: 8px; align-items: center; }
button { font: inherit; cursor: pointer; border: 1px solid var(--line); background: var(--surface); color: var(--fg); border-radius: 8px; padding: 6px 12px; }
button:hover { border-color: var(--accent); } button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.link { border: 0; background: none; color: var(--accent); padding: 4px 8px; font-weight: 700; }
button.toggle { font-weight: 600; } button.toggle.on { background: var(--accent-soft); border-color: var(--accent); }
button[disabled] { opacity: .5; cursor: not-allowed; }
.legend { padding: 8px 16px; background: var(--accent-soft); border-bottom: 1px solid var(--line); display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: .92rem; }
.legend i, .light { display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; background: var(--unknown); }
.l-ok, .light.ok { background: var(--ok); } .l-problem, .light.problem { background: var(--problem); } .l-unknown, .light.unknown { background: var(--unknown); }
.l-none, .light.not_needed { background: var(--none); } .l-hand, .light.hand { background: var(--hand); } .light.warn { background: var(--warn); }
.shell { display: grid; grid-template-columns: 170px 1fr; min-height: calc(100vh - 50px); }
.menu { padding: 12px 8px; border-right: 1px solid var(--line); display: flex; flex-direction: column; gap: 2px; }
.menu a { display: block; padding: 8px 10px; border-radius: 8px; color: var(--fg); text-decoration: none; }
.menu a.active, .menu a:hover { background: var(--accent-soft); color: var(--accent); }
.menu .divider { margin: 14px 10px 4px; font-size: .74rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); border-top: 1px solid var(--line); padding-top: 10px; }
main { padding: 18px 20px 48px; min-width: 0; max-width: 1200px; }
h1 { font-size: 1.5rem; margin: 0 0 4px; } h2 { font-size: 1.15rem; margin: 22px 0 8px; } h3 { font-size: 1rem; margin: 14px 0 6px; }
.what { color: var(--muted); margin: 0 0 14px; max-width: 70ch; }
.muted { color: var(--muted); } .small { font-size: .88rem; } .mono { font-family: var(--mono); font-size: .88em; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 10px 0; min-width: 0; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; }
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.cols { display: grid; grid-template-columns: 3fr 2fr; gap: 14px; } @media (max-width: 900px) { .cols { grid-template-columns: 1fr; } }
table { border-collapse: collapse; width: 100%; font-size: .93rem; } th, td { text-align: left; vertical-align: top; padding: 6px 9px; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 600; font-size: .85rem; } tr.click { cursor: pointer; } tr.click:hover { background: var(--accent-soft); }
.tablewrap { overflow-x: auto; }
.word { font-weight: 700; } .word.OK { color: var(--ok); } .word.Problem { color: var(--problem); } .word.Not { color: var(--muted); }
.problems { list-style: none; padding: 0; margin: 0; } .problems li { padding: 6px 0; border-bottom: 1px dashed var(--line); }
.problems .lvl { display: inline-block; min-width: 10px; height: 10px; border-radius: 3px; margin-right: 8px; }
.pill { display: inline-block; padding: 1px 8px; border-radius: 999px; background: var(--accent-soft); font-size: .85rem; font-family: var(--mono); }
.deck { display: grid; grid-template-columns: repeat(8, 1fr); gap: 5px; background: var(--deck); padding: 8px; border-radius: 12px; max-width: 640px; }
.key { aspect-ratio: 1; border-radius: 6px; display: flex; align-items: center; justify-content: center; text-align: center; padding: 3px; font: 600 .68rem/1.1 var(--font); white-space: pre-line; cursor: pointer; border: 2px solid transparent; }
.key.sel { border-color: #fff; } .key.empty { background: #2b2f2d; cursor: default; opacity: .5; } .key.nav { background: #37474f; color: #fff; cursor: default; }
@keyframes blink { 0%, 49% { background: var(--b1); color: var(--f1); } 50%, 100% { background: var(--b2); color: var(--f2); } }
.key.blink { animation: blink 1s steps(1) infinite; } @media (prefers-reduced-motion: reduce) { .key.blink { animation: none; } }
.tabs { display: flex; gap: 6px; margin: 8px 0 12px; flex-wrap: wrap; } .tabs button.on { background: var(--accent-soft); border-color: var(--accent); }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; align-items: baseline; } .kv dt { color: var(--muted); } .kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
details { margin: 6px 0; } summary { cursor: pointer; color: var(--muted); }
pre.log { background: var(--deck); color: #d6dbe4; padding: 10px 12px; border-radius: 10px; font: .8rem/1.5 var(--mono); overflow: auto; max-height: 70vh; white-space: pre-wrap; }
.field { margin: 6px 0; } .field label { display: block; color: var(--muted); font-size: .85rem; } .field .val { font-family: var(--mono); font-size: .9em; overflow-wrap: anywhere; }
.help { color: var(--muted); font-size: .85rem; }
.searchresults { position: absolute; left: 190px; right: 16px; top: 46px; z-index: 6; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,.18); max-height: 50vh; overflow: auto; }
.searchresults a { display: block; padding: 8px 12px; color: var(--fg); text-decoration: none; border-bottom: 1px solid var(--line); } .searchresults a:hover { background: var(--accent-soft); }
.dialog { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; z-index: 9; padding: 16px; }
.dialog .box { background: var(--surface); border-radius: 12px; padding: 18px; max-width: 460px; width: 100%; }
.dialog input { width: 100%; padding: 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--fg); font: inherit; margin: 8px 0; }
.notice { padding: 8px 12px; border-radius: 8px; background: var(--accent-soft); margin: 8px 0; } .notice.grey { background: var(--bg); border: 1px solid var(--line); color: var(--muted); }
@media (max-width: 760px) {
.shell { grid-template-columns: 1fr; } .menu { flex-direction: row; flex-wrap: wrap; border-right: 0; border-bottom: 1px solid var(--line); }
.menu .divider { border: 0; margin: 0 6px; padding: 8px 0; } main { padding: 14px 16px 40px; } .searchresults { left: 16px; }
.top { flex-wrap: wrap; } #search { order: 3; flex-basis: 100%; }
}