From 422e92f814e3321dac1501f3594376ae2a76f050 Mon Sep 17 00:00:00 2001 From: christian Date: Thu, 15 Oct 2026 11:40:05 -0400 Subject: [PATCH] Settings page: add and edit everything by hand, READY page included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Keys › READY page: + READY key (tap to confirm, a checklist line the page checks itself, or any check), and the same check/fix editor the Status page keys have; delete a key. - Settings › READY page: when gaps turn red (calendar), every address and folder it looks at, and the lists the slides must match. - Help: "How do I add…", step by step: a Companion connection, a ping, a program, a fix, auto-fix, a checklist line, where READY looks, a computer, undo. Co-Authored-By: Claude Opus 5.5 --- settings/ui/app.js | 92 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 71 insertions(+), 21 deletions(-) diff --git a/settings/ui/app.js b/settings/ui/app.js index b3ebf38..afaa277 100644 --- a/settings/ui/app.js +++ b/settings/ui/app.js @@ -169,7 +169,7 @@ function fieldFor(path) { const F = state.schema.fields; if (F[path]) return F[path]; - const generic = path.replace(/^tiles\.\d+\.checks\.\d+\./, "tiles[].checks[].").replace(/^tiles\.\d+\./, "tiles[].") + const generic = path.replace(/^(tiles|ready_page\.keys)\.\d+\.checks\.\d+\./, "tiles[].checks[].").replace(/^(tiles|ready_page\.keys)\.\d+\./, "tiles[].") .replace(/^restart_page\.keys\.\d+\./, "restart_page.keys[].").replace(/^fixes\.[^.]+\./, "fixes.*.").replace(/^hosts\.[^.]+\./, "hosts.*.") .replace(/^gear\.[^.]+\./, "gear.*.").replace(/^(auto_fix\.windows|site\.live_services|service_windows)\.\d+\./, "$1[]."); return F[generic] || null; @@ -311,20 +311,48 @@ const tabs = `
${c.restart_page ? `` : ""}${c.ready_page ? `` : ""} ${editing() ? `page ${c.restart_page ? ` · restart page ` : ""} ` : ""}
`; let panel = `

Click a key.${editing() ? " Drag to move; drop on the tray to hide." : ""}

`; - if (ready) { const i = sel && c.ready_page ? c.ready_page.keys.findIndex((x) => x.id === sel) : -1; panel = await readyKeyPanel(i, hold); } + if (ready) { + const i = sel && c.ready_page ? c.ready_page.keys.findIndex((x) => x.id === sel) : -1; + panel = await readyKeyPanel(i, hold); + if (i >= 0 && editing() && c.ready_page.keys[i].kind !== "ready") panel += await keyPanel(i, hold, "ready_page.keys"); + } else 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 trayKeys = c.tiles.filter((t) => !t.pos && t.kind !== "master"); const tray = trayKeys.map((t) => ``).join(" "); let note = ""; if (state.draft) { try { const v = await post("/api/validate", {}); if (v.plan && v.plan.pages.length) note = `
Layout changed: Apply re-imports the ${v.plan.pages.filter((p) => p === "status" || p === "restart" || p === "ready").map((p) => p[0].toUpperCase() + p.slice(1)).join(" and ")} page.
`; } catch (e) {} } - const adders = editing() && !ready ? (restart ? `` : ``) : ""; + const adders = editing() ? (ready ? (c.ready_page ? `` : "") : restart ? `` : ``) : ""; main.innerHTML = `

Keys${hint("The deck as people hold it. Click a key to see what it watches and what holding it does.")}

${tabs}${note}
${restart && !c.restart_page ? "

No Restart page.

" : ready && !c.ready_page ? "

No READY page.

" : deckHtml(o.deck, c, restart ? "restart" : ready ? "ready" : "status", sel, true)} ${!restart && !ready ? `

In a group only${hint("Keys with no spot of their own: they show inside their group key. Drop a key here to hide it.")}: ${tray || "none"}

` : ""}
${adders}
${panel}
`; - wireKeyPanel(sel, restart || ready); + wireKeyPanel(sel, restart, ready ? "ready_page.keys" : "tiles"); wireLayout(restart); + const nd = main.querySelector("#newdkey"); if (nd) nd.onclick = () => newReadyKey(); + } + // A new READY page key: a checklist line the page checks itself, a tap-to-confirm line, or any check. + function newReadyKey() { + const c = cfg(); + dialog(`

New READY key

+ + +
`, (b) => { + const kind = b.querySelector("#nkind"), item = b.querySelector("#nitem"); + kind.onchange = () => { item.hidden = kind.value !== "ready"; }; + b.querySelector("#dcancel").onclick = closeDialog; + b.querySelector("#dok").onclick = () => { + const label = b.querySelector("#nlabel").value.trim().toUpperCase(); + if (!label) return alertBox("Give it a label."); + const pos = parsePos(b.querySelector("#npos").value, cfg(), "ready", false); if (!pos) return; + if (!ensureDraft()) return; + const keys = state.draft.config.ready_page.keys; + let id = "rd_" + (slug(label) || "key"), n = 2; while (keys.some((k) => k.id === id) || state.draft.config.tiles.some((t) => t.id === id)) id = "rd_" + slug(label) + "_" + n++; + const checks = kind.value === "manual" ? [{ type: "ready_manual", item: slug(label) || id }] + : kind.value === "ready" ? [{ type: "ready", item: item.value, fix: "ready_recheck" }] : []; + keys.push({ id, label, pos, checks }); saveDraft(); closeDialog(); location.hash = "#keys/ready/" + id; + }; + }); } function takenCells(c, which) { const taken = new Set(["0,0", "1,0", "2,0"]); @@ -416,8 +444,11 @@ }); }; } - async function keyPanel(i, hold) { - const c = cfg(), t = c.tiles[i]; + // A Status page tile ("tiles") or a READY page key ("ready_page.keys"): the same editor for both. + const keyList = (c, list) => (list === "ready_page.keys" ? (c.ready_page || { keys: [] }).keys : c.tiles); + const READY_ONLY = ["ready", "ready_manual"]; + async function keyPanel(i, hold, list = "tiles") { + const c = cfg(), t = keyList(c, list)[i], onReady = list !== "tiles"; const k = await api("/api/keys"); const kt = k.tiles.find((x) => x.id === t.id) || { says: [], holding: "", checks: [] }; // A Companion connection is picked from the ones Companion has (typing still works). @@ -434,9 +465,13 @@ const header = q !== lastQ ? `
${esc(q)}
` : ""; lastQ = q; const summary = `${what}${hint(sentence)}`; if (!editing()) return `${header}
${summary}

${esc(sentence)}

`; - const base = `tiles.${i}.checks.${j}.`; + const base = `${list}.${i}.checks.${j}.`; let fields = (spec.fields || []).filter((f) => !["fix", "type", "template", "params"].includes(f)).map((f) => f === "conn" ? inputHtml(base + f, F("tiles[].checks[]." + f), chk[f], connList).replace("checklist line${hint("What the READY page looks at for this key")}` + : f === "item" && chk.type === "ready_manual" + ? inputHtml(base + f, Object.assign({}, F("tiles[].checks[].item"), { help: "A short name for what is tapped done, like green_room (its tap is remembered by this name for the week)" }), chk[f]) : inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join(""); if (chk.type === "remote") { const ctpls = state.builders.checks.concat([{ name: "custom", words: "Custom command (typed by hand)", params: [] }]); @@ -451,13 +486,14 @@
more${["tag", "service_only", "debounce_seconds"].filter((f) => !(spec.fields || []).includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("")}

type ${esc(chk.type)}

${run}
`; }).join(""); - const plain = !t.members && t.kind !== "master"; - const members = t.members ? (editing() ? `
group members${c.tiles.filter((x) => x.id !== t.id && !x.members && x.kind !== "master").map((x) => ``).join(" ")}
` : `

Sums up ${t.members.map((m) => `${esc((c.tiles.find((x) => x.id === m) || {}).label || m)}`).join(", ")}

`) : ""; - const addMenu = editing() && plain ? `` : ""; - const label = editing() ? inputHtml(`tiles.${i}.label`, F("tiles[].label"), t.label) : ""; - const holdAlways = editing() && plain ? `
hold also when green${hint("A fix that runs on every hold, even with nothing failing")}
` : ""; + const plain = !t.members && t.kind !== "master" && t.kind !== "ready"; + const members = t.members && !onReady ? (editing() ? `
group members${c.tiles.filter((x) => x.id !== t.id && !x.members && x.kind !== "master").map((x) => ``).join(" ")}
` : `

Sums up ${t.members.map((m) => `${esc((c.tiles.find((x) => x.id === m) || {}).label || m)}`).join(", ")}

`) : ""; + const addMenu = editing() && plain ? `` : ""; + const label = editing() && !onReady ? inputHtml(`tiles.${i}.label`, F("tiles[].label"), t.label) : ""; + const holdAlways = editing() && plain && !onReady ? `
hold also when green${hint("A fix that runs on every hold, even with nothing failing")}
` : ""; const holdList = (kt.holding || "").split(/;\s*/).filter(Boolean); - const more = `
more
id
${esc(t.id)}
${t.kind ? `
kind
${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}
` : ""}
${editing() && !t.kind ? `
position${hint("row, column; blank = inside its group only")}
` : ""}${editing() && (!t.kind || t.kind === "network") ? `

` : ""}
`; + const more = `
more
id
${esc(t.id)}
${t.kind ? `
kind
${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}
` : ""}
${editing() && !t.kind && !onReady ? `
position${hint("row, column; blank = inside its group only")}
` : ""}${editing() && (!t.kind || t.kind === "network") ? `

` : ""}
`; + if (onReady) return `

Edit ${esc(t.label)}

${checks ? `
${checks}
` : ""}${addMenu}${more}
`; return `

${esc(t.label)} ${t.pos ? `row ${t.pos[0]}, col ${t.pos[1]}` : "in a group"}

${label}${members}${holdAlways} ${holdList.length ? `
Hold ${esc(hold)}: ${esc(short(holdList[0], 48))}${holdList.length > 1 ? ` +${holdList.length - 1}` : ""}${t.hold_always ? " also when green" : ""}
    ${holdList.map((h) => `
  1. ${esc(h)}
  2. `).join("")}
` : ""} @@ -465,19 +501,20 @@ ${checks ? `
${checks}
` : ""}${addMenu}${more}
`; } 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) { + function wireKeyPanel(sel, restart, list = "tiles") { const c = cfg(); - const i = restart ? -1 : c.tiles.findIndex((x) => x.id === sel); + const i = restart ? -1 : keyList(c, list).findIndex((x) => x.id === sel); + const mine = () => keyList(state.draft.config, list)[i]; 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 dk = main.querySelector("#delkey"); if (dk) dk.onclick = () => confirmBox(`Delete the key ${keyList(c, list)[i].label}? Its checks go with it.`, "Delete", () => { if (!ensureDraft()) return; const cc = state.draft.config; const keys = keyList(cc, list); const id = keys[i].id; keys.splice(i, 1); for (const t of cc.tiles) if (t.members) t.members = t.members.filter((m) => m !== id); saveDraft(); location.hash = list === "tiles" ? "#keys" : "#keys/ready"; }); 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 b of main.querySelectorAll("[data-removecheck]")) b.onclick = () => confirmBox("Remove this check from the key?", "Remove", () => { if (!ensureDraft()) return; mine().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 add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = mine(); t.checks = t.checks || []; t.checks.push(add.value === "ready" ? { type: "ready", fix: "ready_recheck" } : { type: add.value }); 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]; + const t = mine(), chk = t.checks[j]; const tplName = main.querySelector(`select.ctplsel[data-check="${j}"]`).value; if (tplName === "custom") { delete chk.template; delete chk.params; saveDraft(); route(); return; } const ct = state.builders.checks.find((x) => x.name === tplName); @@ -765,6 +802,7 @@ const glossary = Object.entries(gl).filter(([g]) => !/while you hold/.test(g)).map(([g, words]) => `
${esc(g)}

${esc(words.join(" · "))}

`).join(""); main.innerHTML = `

Help

${cards}

What each key does Cheat sheet Where things are

+
How do I add… (everything is done here; nothing needs a terminal)
  • Watch a new Companion connection. Edit (top right, the PIN) › Settings › Companion. Find it marked “not watched” › + Key. When the Status page is full it asks which group key to put it in (AUDIO, SHOW…): its problems show on that key, and holding the key reconnects it. Then the badge › Apply.
  • Add a connection to a key that is already there. Keys › click the key › + check… › “A Companion connection is good” › pick the connection from the list › fix: “reconnect its Companion connection” › Apply.
  • Watch that a device is on the network. Keys › the key › + check… › “An address answers ping” › type the address and the name the key should show (it says “NAME NO PING”).
  • Watch a program on another computer, and restart it. Keys › the key › + check… › “A command on another computer succeeds” › built as “A program is running on a Mac” › pick the computer and the program (on a Windows PC: Custom, e.g. tasklist | find /I \"OBS.exe\"). Then fix › a fix that starts it (make one first: next line).
  • Make a new fix (something a hold can do). Keys › Fixes › + Fix › pick how it is built (start a Windows program, restart a Mac program, restart a helper, reopen Ableton…) and fill in the fields; the command is written for you. Then choose it as a check's “fix”, and use “Run the fix” to try it.
  • Let a fix run by itself on Sundays. Settings › Auto-fix › tick it, and paint the hours on the calendar. A fix that closes a running program asks first.
  • Add a line to the weekly checklist. Keys › READY page › + READY key › “Tap to confirm” (a person taps it done; resets Monday), “a checklist line the page checks by itself”, or “something else” (then + check… like any key: a connection, a ping, a program). Drag it where you want it.
  • Change where the READY page looks. Settings › READY page: the Planning Center service type, the ProPresenter and Resolume addresses, the Tracks Mac and its projects folder, which locations get tracks, the lineup TV, the bundle folder, and when gaps turn red.
  • A new computer. Computers › + Computer (its log-in address), then Computers › Setup › that computer › copy its set-up script and run it there once.
  • Made a mistake?. Nothing is live until Apply. After Apply: History › Undo, then Apply again.
Try it (ten minutes with Editing on; nothing is live until APPLY)
  1. Keys › HD2: untick "may have no disk"; the badge counts it; Apply reads it back. Throw away.
  2. Keys: drag LED onto the tray and back. + Key "HD7", add a check (Is a recorder OK?), delete it.
  3. @@ -782,10 +820,12 @@ } // ---- Settings: one page, every section ------------------------------------------ - const HAND_MADE = ["tiles", "fixes", "hosts", "gear", "auto_fix.windows", "service_windows", "site.live_services", "auto_fix.fixes", "atem.reference", "spotify.playlist", "spotify.name", "restart_page.keys", "site.editing_code"]; + const HAND_MADE = ["tiles", "fixes", "hosts", "gear", "auto_fix.windows", "service_windows", "site.live_services", "auto_fix.fixes", "atem.reference", "spotify.playlist", "spotify.name", "restart_page.keys", "site.editing_code", + "ready_page.keys", "ready_page.back_pos", "ready.prop_lists"]; const SECTIONS = [ ["playlist", "Playlist", "The Spotify playlist the SPOTIFY key and the play fix use"], ["autofix", "Auto-fix", "When the panel may fix things by itself, and which fixes"], + ["ready", "READY page", "Where the weekly checklist looks: Planning Center, ProPresenter, the Tracks Mac, the lineup TV, Resolume, the bundles"], ["wait", "Changes wait", "When changes to the panel must wait (a recording deck always counts)"], ["hyperdecks", "HyperDecks", "Which decks are in use"], ["midi", "MIDI links", "Network MIDI sessions and the peers that must stay in them"], @@ -809,6 +849,7 @@ let body = ""; if (which === "playlist") body = spotifyHtml(c); else if (which === "autofix") body = autofixHtml(c); + else if (which === "ready") body = readySettingsHtml(c); else if (which === "wait") body = `
    ${calendarHtml("site.live_services", (c.site || {}).live_services)}

    A recording HyperDeck always counts, whatever the clock says.

    `; else if (which === "hyperdecks") body = await hyperdecksHtml(c); else if (which === "midi") body = midiHtml(c); @@ -825,6 +866,14 @@ main.querySelector("#subsel").onchange = (e) => { location.hash = "#settings/" + e.target.value; }; wireSettings(which); } + function readySettingsHtml(c) { + if (!c.ready_page) return "

    This campus has no READY page.

    "; + const lists = JSON.stringify((c.ready || {}).prop_lists || [], null, 1); + return `

    When gaps turn red${hint("Before this, a READY key that is not done is amber; inside it, still missing is red and blinks")}

    ${calendarHtml("ready.due", (c.ready || {}).due)}
    ` + + fieldsHtml("Ready") + + `
    lists the slides must match${hint('PCO items whose description lists things: {"match": a word in the item title, "kind": "count" (one slide per - line) or "names" (every name has a slide), "short": what the key calls it}')}
    +

    The keys themselves (add, move, delete, what each checks) are on Keys › READY page.

    `; + } function fieldsHtml(screen, includeAdvanced) { const own = Object.entries(state.schema.fields).filter(([p, f]) => f.screen === screen && !p.includes("[]") && !p.includes("*") && !HAND_MADE.includes(p) && !HAND_MADE.some((h) => h.startsWith(p + ".")) && !Object.keys(state.schema.fields).some((k) => k.startsWith(p + ".") || k.startsWith(p + "[]"))); const plain = own.filter(([, f]) => !f.advanced), adv = own.filter(([, f]) => f.advanced); @@ -1011,6 +1060,7 @@ function wireSettings(which) { const q = (s) => main.querySelector(s); wireCalendars(); + for (const ta of main.querySelectorAll("[data-jsonpath]")) ta.onchange = () => { try { setPath(ta.dataset.jsonpath, JSON.parse(ta.value)); } catch (e) { alertBox("That is not valid: " + e.message); } }; for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job); const ok = q("#okeys"); if (ok) ok.onclick = async () => { ok.disabled = true; ok.textContent = "Testing…"; try { const r = await post("/api/outlets/keys", { access_id: q("#oid").value, access_secret: q("#osecret").value, region: q("#oregion").value }); alertBox(r.ok ? `Works: ${r.devices} devices on the account.` : r.error); route(); } catch (e) { alertBox(e.message); route(); } }; const oa = q("#oadd"); if (oa) oa.onclick = outletDialog; @@ -1214,7 +1264,7 @@ detail = `

    ${esc(f.short || id)} ${esc(f.label || "")}

    ${inputs}${keys.length ? `

    Used by ${esc(keys.join(", "))}.

    ` : ""}
    everything stored
    ${Object.entries(f).map(([k, v]) => `
    ${esc(k)}
    ${esc(typeof v === "object" ? JSON.stringify(v) : v)}
    `).join("")}
    id
    ${esc(id)}
    ${editing() && !keys.length ? `` : ""}
    `; } const add = editing() ? `` : ""; - main.innerHTML = `

    Keys${hint("Everything a hold can run. Click a fix to see how it is built.")}

    ${c.restart_page ? `` : ""}${add}
    + main.innerHTML = `

    Keys${hint("Everything a hold can run. Click a fix to see how it is built.")}

    ${c.restart_page ? `` : ""}${c.ready_page ? `` : ""}${add}
    ${rows}
    key textdoesbuilt asused byruns
    ${detail}
    `; const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value === "another computer" ? "ssh" : ks.value === "this Mac" ? "local" : ks.value); route(); }; const rebuild = async () => {