Step 3: the layout - drag keys to move or hide them, add and delete keys, page numbers, the Restart page switch

Keys screen: every key is a draggable chip (empty cells and the tray are drop zones), + New key and
+ New restart key dialogs, Delete this key, Companion page numbers, 'this campus has a Restart
page'. A taken cell is refused on the spot; 'Layout changed: Apply will re-import ...' comes from
the server's plan. Screens wait for a pending draft save before asking the server about it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 14:25:09 -04:00
co-authored by Claude Fable 5.1
parent 187dd464c0
commit dafa14a512
2 changed files with 132 additions and 23 deletions
+128 -23
View File
@@ -101,17 +101,22 @@
function saveDraft() {
badge();
clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(async () => {
try {
const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version }, "PUT");
state.draft.version = r.draft.version; state.draft.by = r.draft.by; state.draft.started = r.draft.started; state.draft.started_by = r.draft.started_by;
} catch (e) {
if (e.status === 409) { alertBox(`${e.body.error}: ${(e.body.changed || []).join(", ") || "the draft"}. The page now shows their version.`); state.draft = e.body.draft; route(); }
else if (e.status === 401 || e.status === 403) { alertBox(e.message); state.editing = null; state.name = null; store.set("sp-code", null); showEditing(); }
else alertBox("Could not save the draft: " + e.message);
}
}, 400);
state.saving = new Promise((done) => {
state.saveTimer = setTimeout(async () => {
try {
const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version }, "PUT");
state.draft.version = r.draft.version; state.draft.by = r.draft.by; state.draft.started = r.draft.started; state.draft.started_by = r.draft.started_by;
} catch (e) {
if (e.status === 409) { alertBox(`${e.body.error}: ${(e.body.changed || []).join(", ") || "the draft"}. The page now shows their version.`); state.draft = e.body.draft; route(); }
else if (e.status === 401 || e.status === 403) { alertBox(e.message); state.editing = null; state.name = null; store.set("sp-code", null); showEditing(); }
else alertBox("Could not save the draft: " + e.message);
}
done();
}, 400);
});
return state.saving;
}
const flush = () => state.saving || Promise.resolve();
// set a value at a dotted path in the draft (creating objects on the way)
function setPath(path, value) {
if (!ensureDraft()) return;
@@ -261,42 +266,138 @@
});
}
function deckHtml(deck, c, which, selected) {
function deckHtml(deck, c, which, selected, draggable) {
const cells = {};
const rp = c.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 c.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 : c.page), "2,0": "DOWN" };
const drag = (what) => editing() && draggable ? ` draggable="true" data-drag="${esc(what)}"` : "";
let out = `<div class="deck">`;
for (let r = 0; r < 4; r++) for (let col = 0; col < 8; col++) {
const k = `${r},${col}`, 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>`;
if (nav[k]) out += `<div class="key nav locked">${esc(nav[k])}</div>`;
else if (cell && cell.nav) out += `<div class="key nav"${drag(cell.nav.startsWith("RESTART") ? "jump:" : "back:")}>${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>`;
out += `<div class="key${blink}${selected === cell.id ? " sel" : ""}" style="${style}"${drag((which === "restart" ? "rkey:" : "tile:") + cell.id)} onclick="location.hash='#keys/${which === "restart" ? "restart/" : ""}${esc(cell.id)}'">${esc(s ? s.x : cell.label)}</div>`;
} else out += `<div class="key empty"${editing() && draggable ? ` data-drop="${r},${col}"` : ""}></div>`;
}
return out + `</div>`;
}
// ---- Keys ------------------------------------------------------------------
async function keys(rest) {
await flush();
const o = await api("/api/overview").catch(() => ({ deck: null }));
const c = cfg();
const restart = rest[0] === "restart";
const sel = restart ? rest[1] : rest[0];
const hold = ((c.page_hold_ms || 2000) / 1000) + " s";
const tabs = `<div class="tabs"><button class="${restart ? "" : "on"}" onclick="location.hash='#keys'">Status page (page ${c.page})</button>${c.restart_page ? `<button class="${restart ? "on" : ""}" onclick="location.hash='#keys/restart'">Restart page (page ${c.restart_page.page})</button>` : ""}</div>`;
let panel = `<p class="muted">Click a key to see what it watches and what holding it does.${editing() ? " With Editing on you can change it here." : ""}</p>`;
const tabs = `<div class="tabs"><button class="${restart ? "" : "on"}" onclick="location.hash='#keys'">Status page (page ${c.page})</button>${c.restart_page ? `<button class="${restart ? "on" : ""}" onclick="location.hash='#keys/restart'">Restart page (page ${c.restart_page.page})</button>` : ""}</div>
${editing() ? `<p class="small row">In Companion the Status page is page <input class="pagenum" data-which="status" type="number" min="1" value="${c.page}">${c.restart_page ? ` and the Restart page is page <input class="pagenum" data-which="restart" type="number" min="1" value="${c.restart_page.page}">` : ""} <label class="inline"><input type="checkbox" id="hasrestart" ${c.restart_page ? "checked" : ""}> this campus has a Restart page</label></p>` : ""}`;
let panel = `<p class="muted">Click a key to see what it watches and what holding it does.${editing() ? " With Editing on you can change it here, drag a key to move it, drag it onto the tray to hide it." : ""}</p>`;
if (sel && !restart) { const i = c.tiles.findIndex((x) => x.id === sel); if (i >= 0) panel = await keyPanel(i, hold); }
else if (sel && restart && c.restart_page) { const i = c.restart_page.keys.findIndex((x) => x.id === sel); if (i >= 0) panel = restartKeyPanel(i, hold); }
const tray = c.tiles.filter((t) => !t.pos && t.kind !== "master").map((t) => `<button class="small" 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. Click a key; with Editing on, change what it watches and what holding it does (moving keys arrives with step 3).</p>${tabs}
<div class="cols"><div>${deckHtml(o.deck, c, 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 id="panel">${panel}</div></div>`;
const trayKeys = c.tiles.filter((t) => !t.pos && t.kind !== "master");
const tray = trayKeys.map((t) => `<button class="small"${editing() ? ` draggable="true" data-drag="tile:${esc(t.id)}"` : ""} onclick="location.hash='#keys/${esc(t.id)}'">${esc(t.label)}</button>`).join(" ");
let note = "";
if (state.draft) { try { const v = await post("/api/validate", {}); if (v.plan && v.plan.pages.length) note = `<div class="notice">Layout changed: Apply will re-import the ${v.plan.pages.filter((p) => p === "status" || p === "restart").map((p) => p[0].toUpperCase() + p.slice(1)).join(" and ")} page.</div>`; } catch (e) {} }
const adders = editing() ? (restart ? `<button id="newrkey" class="small">+ New restart key</button>` : `<button id="newkey" class="small">+ New key</button>`) : "";
main.innerHTML = `<h1>Keys</h1><p class="what">The deck as people hold it. Click a key; with Editing on, change what it watches and what holding it does, drag it to move it.</p>${tabs}${note}
<div class="cols"><div>${restart && !c.restart_page ? "<p class='muted'>This campus has no Restart page.</p>" : deckHtml(o.deck, c, restart ? "restart" : "status", sel, true)}
${!restart ? `<p class="small muted tray" id="tray" style="margin-top:8px">Shown inside their group, no key of their own: ${tray || "<span class='muted'>(none)</span>"}${editing() ? " <span class='muted'>· drop a key here to hide it</span>" : ""}</p>` : ""}
<div class="row" style="margin-top:8px">${adders}</div></div><div id="panel">${panel}</div></div>`;
wireKeyPanel(sel, restart);
wireLayout(restart);
}
function takenCells(c, which) {
const taken = new Set(["0,0", "1,0", "2,0"]);
if (which === "restart") { for (const k of c.restart_page.keys) taken.add(String(k.pos)); taken.add(String(c.restart_page.back_pos)); }
else { for (const t of c.tiles) if (t.pos) taken.add(String(t.pos)); if (c.restart_page) taken.add(String(c.restart_page.jump_pos)); }
return taken;
}
function firstEmpty(c, which) {
const taken = takenCells(c, which);
for (let r = 0; r < 4; r++) for (let col = 0; col < 8; col++) if (!taken.has(`${r},${col}`)) return [r, col];
return null;
}
function parsePos(text, c, which, allowBlank) {
const m = text.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/);
if (!m) { if (allowBlank && !text.trim()) return null; alertBox("Row, column: two numbers, like 2, 5."); return false; }
const pos = [+m[1], +m[2]];
if (pos[0] > 3 || pos[1] > 7) { alertBox("The deck has rows 0-3 and columns 0-7."); return false; }
if (takenCells(c, which).has(String(pos))) { alertBox(`Row ${pos[0]} column ${pos[1]} is taken. Hide or move a key first` + (allowBlank ? ", or leave the position blank." : ".")); return false; }
return pos;
}
function wireLayout(restart) {
const move = (what, pos) => {
if (!ensureDraft()) return;
const c = state.draft.config, [kind, id] = what.split(":");
if (kind === "tile") { const t = c.tiles.find((x) => x.id === id); if (!t) return; if (pos) t.pos = pos; else delete t.pos; }
else if (kind === "rkey") { const k = c.restart_page.keys.find((x) => x.id === id); if (k && pos) k.pos = pos; }
else if (kind === "jump" && pos) c.restart_page.jump_pos = pos;
else if (kind === "back" && pos) c.restart_page.back_pos = pos;
saveDraft(); route();
};
let dragging = null;
for (const el of main.querySelectorAll("[data-drag]")) {
el.addEventListener("dragstart", (e) => { dragging = el.dataset.drag; e.dataTransfer.setData("text/plain", dragging); e.dataTransfer.effectAllowed = "move"; });
el.addEventListener("dragend", () => { dragging = null; });
}
for (const el of main.querySelectorAll("[data-drop]")) {
el.addEventListener("dragover", (e) => { e.preventDefault(); el.classList.add("over"); });
el.addEventListener("dragleave", () => el.classList.remove("over"));
el.addEventListener("drop", (e) => { e.preventDefault(); const what = e.dataTransfer.getData("text/plain") || dragging; if (what) move(what, el.dataset.drop.split(",").map(Number)); });
}
const tray = main.querySelector("#tray");
if (tray && editing()) {
tray.addEventListener("dragover", (e) => { e.preventDefault(); tray.classList.add("over"); });
tray.addEventListener("dragleave", () => tray.classList.remove("over"));
tray.addEventListener("drop", (e) => { e.preventDefault(); const what = e.dataTransfer.getData("text/plain") || dragging; if (!what || !what.startsWith("tile:")) return; const t = cfg().tiles.find((x) => x.id === what.slice(5)); if (t && t.kind) return alertBox("That key always has a spot on the deck."); move(what, null); });
}
for (const inp of main.querySelectorAll("input.pagenum")) inp.onchange = () => {
const n = parseInt(inp.value, 10); if (!(n >= 1)) return;
if (!ensureDraft()) return;
const c = state.draft.config;
if (inp.dataset.which === "status") { c.page = n; c.page_name = `${n}\n${(c.page_name || "\nStatus").split("\n").slice(1).join("\n") || "Status"}`; }
else { c.restart_page.page = n; c.restart_page.page_name = `${n}\n${(c.restart_page.page_name || "\nRestart").split("\n").slice(1).join("\n") || "Restart"}`; }
saveDraft(); route();
};
const hr = main.querySelector("#hasrestart"); if (hr) hr.onchange = () => {
if (!ensureDraft()) { hr.checked = !hr.checked; return; }
const c = state.draft.config;
if (!hr.checked) confirmBox("Switch the Restart page off? Its keys are removed from the settings; page " + c.restart_page.page + " stays in Companion until someone clears it.", "Switch it off", () => { delete c.restart_page; saveDraft(); location.hash = "#keys"; route(); }, null);
else { const pos = firstEmpty(c, "status"); if (!pos) return alertBox("No free spot on the Status page for the RESTART PAGE key."); c.restart_page = { page: c.page + 1, page_name: `${c.page + 1}\nRestart`, jump_pos: pos, back_pos: pos, keys: [] }; saveDraft(); location.hash = "#keys/restart"; route(); }
if (!hr.checked) hr.checked = true;
};
const nk = main.querySelector("#newkey"); if (nk) nk.onclick = () => dialog(`<h3>New key</h3><input id="nlabel" placeholder="label, as the key shows it (HD7)"><input id="nid" placeholder="id (letters, digits, _) – filled from the label"><input id="npos" placeholder="row, column" value="${(firstEmpty(cfg(), "status") || []).join(", ")}"><div class="row"><button class="primary" id="dok">Add the key</button><button id="dcancel">Cancel</button></div><p class="help">It starts with no checks: add them on the key, then Apply.</p>`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#nlabel").oninput = () => { b.querySelector("#nid").value = b.querySelector("#nlabel").value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""); };
b.querySelector("#dok").onclick = () => {
const label = b.querySelector("#nlabel").value.trim(), id = b.querySelector("#nid").value.trim();
if (!label || !/^[A-Za-z0-9_]+$/.test(id)) return;
if (cfg().tiles.some((t) => t.id === id)) return alertBox("A key with that id exists.");
const pos = parsePos(b.querySelector("#npos").value, cfg(), "status", true); if (pos === false) return;
if (!ensureDraft()) return;
const t = { id, label, checks: [] }; if (pos) t.pos = pos;
state.draft.config.tiles.push(t); saveDraft(); closeDialog(); location.hash = "#keys/" + id;
};
});
const nr = main.querySelector("#newrkey"); if (nr) nr.onclick = () => { const c = cfg(); dialog(`<h3>New restart key</h3><input id="nlabel" placeholder="label (PROP TV)"><select id="nfix">${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}">${esc(f.short || id)} – ${esc(f.label || "")}</option>`).join("")}</select><input id="npos" placeholder="row, column" value="${(firstEmpty(c, "restart") || []).join(", ")}"><div class="row"><button class="primary" id="dok">Add the key</button><button id="dcancel">Cancel</button></div>`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = () => {
const label = b.querySelector("#nlabel").value.trim(), fix = b.querySelector("#nfix").value;
if (!label) return;
const pos = parsePos(b.querySelector("#npos").value, cfg(), "restart", false); if (!pos) return;
if (!ensureDraft()) return;
let id = "rs_" + label.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""), n = 2;
while (state.draft.config.restart_page.keys.some((k) => k.id === id)) id = id.replace(/_\d+$/, "") + "_" + n++;
state.draft.config.restart_page.keys.push({ id, label, fix, pos, watch: [] }); saveDraft(); closeDialog(); location.hash = "#keys/restart/" + id;
};
}); };
}
async function keyPanel(i, hold) {
@@ -332,17 +433,20 @@
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>
${kt.says && kt.says.length ? `<p><b>WHAT THIS KEY WILL SAY:</b> <span class="mono">${esc(kt.says.join(", "))}</span></p>` : ""}
<p><b>HOLDING ${esc(hold)} WILL:</b> ${esc(kt.holding)}${t.hold_always ? " <span class='pill'>also when green</span>" : ""}</p>
${label}${members}${holdAlways}${checks ? `<p><b>Checks:</b></p><ol class="checks">${checks}</ol>` : ""}${addMenu}${pos}</div>`;
${label}${members}${holdAlways}${checks ? `<p><b>Checks:</b></p><ol class="checks">${checks}</ol>` : ""}${addMenu}${pos}
${editing() && (!t.kind || t.kind === "network") ? `<p style="margin-top:12px"><button class="small danger" id="delkey">Delete this key</button></p>` : ""}</div>`;
}
function groupBy(obj, fn) { const out = {}; for (const [k, v] of Object.entries(obj)) (out[fn(v)] = out[fn(v)] || []).push(k); return out; }
function wireKeyPanel(sel, restart) {
const c = cfg();
const i = restart ? -1 : c.tiles.findIndex((x) => x.id === sel);
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
const dk = main.querySelector("#delkey"); if (dk) dk.onclick = () => confirmBox(`Delete the key ${c.tiles[i].label}? Its checks go with it.`, "Delete", () => { if (!ensureDraft()) return; const cc = state.draft.config; const id = cc.tiles[i].id; cc.tiles.splice(i, 1); for (const t of cc.tiles) if (t.members) t.members = t.members.filter((m) => m !== id); saveDraft(); location.hash = "#keys"; });
const dr = main.querySelector("#delrkey"); if (dr) dr.onclick = () => confirmBox("Delete this restart key?", "Delete", () => { if (!ensureDraft()) return; const ks = state.draft.config.restart_page.keys; ks.splice(ks.findIndex((x) => x.id === sel), 1); saveDraft(); location.hash = "#keys/restart"; });
for (const b of main.querySelectorAll("[data-removecheck]")) b.onclick = () => confirmBox("Remove this check from the key?", "Remove", () => { if (!ensureDraft()) return; state.draft.config.tiles[i].checks.splice(+b.dataset.removecheck, 1); saveDraft(); route(); });
for (const cb of main.querySelectorAll("[data-member]")) cb.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const set = new Set(t.members); cb.checked ? set.add(cb.dataset.member) : set.delete(cb.dataset.member); t.members = [...set]; saveDraft(); };
const add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = state.draft.config.tiles[i]; t.checks = t.checks || []; t.checks.push({ type: add.value }); saveDraft(); route(); };
const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const m = pos.value.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/); if (m) t.pos = [+m[1], +m[2]]; else if (!pos.value.trim()) delete t.pos; else return alertBox("Row, column: two numbers, like 2, 5."); saveDraft(); route(); };
const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { const t0 = cfg().tiles[i]; const was = t0.pos; delete t0.pos; const p2 = parsePos(pos.value, cfg(), "status", true); if (was) t0.pos = was; if (p2 === false) return; if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; if (p2) t.pos = p2; else delete t.pos; saveDraft(); route(); };
const rebuildCheck = async (j) => {
if (!ensureDraft()) return;
const t = state.draft.config.tiles[i], chk = t.checks[j];
@@ -369,7 +473,7 @@
<div class="field"><label>What holding runs</label><select data-path="restart_page.keys.${i}.fix">${Object.entries(c.fixes).map(([id, fx]) => `<option value="${esc(id)}" ${r.fix === id ? "selected" : ""}>${esc(fx.short || id)} – ${esc(fx.label || "")}</option>`).join("")}</select></div>
<div class="field"><label>Keep flashing HOLD TO FIX while these are down (hold cmd/ctrl for several)</label><select multiple size="5" class="watchsel" data-path="restart_page.keys.${i}.watch">${Object.keys(c.fixes).map((id) => `<option value="${esc(id)}" ${(r.watch || []).includes(id) ? "selected" : ""}>fix ${esc(id)}</option>`).join("")}${[...conns].sort().map((cn) => `<option value="conn:${esc(cn)}" ${(r.watch || []).includes("conn:" + cn) ? "selected" : ""}>connection ${esc(cn)}</option>`).join("")}</select></div>` : "";
return `<div class="card"><h2>${esc(r.label)}</h2><p class="muted">row ${r.pos[0]}, column ${r.pos[1]}</p><p><b>HOLDING ${esc(hold)} WILL:</b> ${esc(f.label || r.fix)}, even when the Status page says it is fine.</p>${r.watch && r.watch.length ? `<p class="small muted">Keeps flashing HOLD TO FIX while down: ${esc(r.watch.join(", "))}</p>` : ""}${edit}
${canRunNow(r.fix) && editing() ? `<button class="small" data-run="${esc(r.id)}|${esc(r.fix)}|0">Run this fix now</button>` : ""}<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd></dl></details></div>`;
${canRunNow(r.fix) && editing() ? `<button class="small" data-run="${esc(r.id)}|${esc(r.fix)}|0">Run this fix now</button>` : ""}<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd></dl></details>${editing() ? `<p style="margin-top:12px"><button class="small danger" id="delrkey">Delete this key</button></p>` : ""}</div>`;
}
// ---- Computers ---------------------------------------------------------------
@@ -479,6 +583,7 @@
// ---- Review & Apply -------------------------------------------------------------
async function review() {
await flush();
const st = await api("/api/apply/status");
if (st.state === "running") return applyProgress(st);
const d = await api("/api/draft"); state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; badge();
+4
View File
@@ -91,3 +91,7 @@ ul.changes li.command { font-family: var(--mono); font-size: .9em; color: var(--
.notice.problem { background: #fdecea; color: #7a1d16; border: 1px solid var(--problem); }
ol.steps { padding-left: 18px; } ol.steps li { margin: 3px 0; }
tr.sel { background: var(--accent-soft); }
/* step 3: layout */
.key[draggable="true"] { cursor: grab; } .key.empty[data-drop] { outline: 1px dashed #555; opacity: .7; } .key.empty.over { outline: 2px solid var(--accent); opacity: 1; }
.tray.over { outline: 2px solid var(--accent); border-radius: 8px; } .tray button[draggable="true"] { cursor: grab; }
input.pagenum { width: 4.5em; padding: 2px 4px; font: inherit; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); }